mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 01:06:24 +00:00
Updates and fixes.
This commit is contained in:
@@ -5,36 +5,28 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV UV_COMPILE_BYTECODE=1
|
ENV UV_COMPILE_BYTECODE=1
|
||||||
|
|
||||||
# Copy dependency files
|
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN uv sync --frozen --no-dev --no-install-project
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY iotDashboard/ ./iotDashboard/
|
COPY iotDashboard/ ./iotDashboard/
|
||||||
COPY manage.py ./
|
COPY manage.py ./
|
||||||
COPY create_user.py ./
|
COPY create_user.py ./
|
||||||
|
|
||||||
# Sync the project
|
|
||||||
RUN uv sync --frozen --no-dev
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
|
||||||
# Stage 2: Runtime
|
|
||||||
FROM python:3.13-alpine
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install runtime dependencies
|
|
||||||
RUN apk add --no-cache postgresql-client
|
RUN apk add --no-cache postgresql-client
|
||||||
|
|
||||||
# Copy virtual environment and application
|
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
COPY --from=builder /app/iotDashboard/ /app/iotDashboard/
|
COPY --from=builder /app/iotDashboard/ /app/iotDashboard/
|
||||||
COPY --from=builder /app/manage.py /app/
|
COPY --from=builder /app/manage.py /app/
|
||||||
COPY --from=builder /app/create_user.py /app/
|
COPY --from=builder /app/create_user.py /app/
|
||||||
|
|
||||||
# Create non-root user
|
|
||||||
RUN adduser -D -u 1000 appuser && \
|
RUN adduser -D -u 1000 appuser && \
|
||||||
chown -R appuser:appuser /app
|
chown -R appuser:appuser /app
|
||||||
|
|
||||||
@@ -45,5 +37,4 @@ ENV PYTHONUNBUFFERED=1
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Run Django with uvicorn for ASGI
|
|
||||||
CMD ["python", "-m", "uvicorn", "iotDashboard.asgi:application", "--host", "0.0.0.0", "--port", "3000"]
|
CMD ["python", "-m", "uvicorn", "iotDashboard.asgi:application", "--host", "0.0.0.0", "--port", "3000"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
RUN uv sync
|
RUN uv sync --no-cache
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
"""add users table
|
|
||||||
|
|
||||||
Revision ID: 7c71d43d53e3
|
|
||||||
Revises: 4b84a36e13f5
|
|
||||||
Create Date: 2025-12-15 21:24:36.718471+00:00
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '7c71d43d53e3'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = '4b84a36e13f5'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('users',
|
|
||||||
sa.Column('id', sa.Text(), nullable=False),
|
|
||||||
sa.Column('username', sa.Text(), nullable=False),
|
|
||||||
sa.Column('email', sa.Text(), nullable=False),
|
|
||||||
sa.Column('password_hash', sa.Text(), nullable=False),
|
|
||||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('email'),
|
|
||||||
sa.UniqueConstraint('username')
|
|
||||||
)
|
|
||||||
op.create_index('idx_users_email', 'users', ['email'], unique=False)
|
|
||||||
op.create_index('idx_users_username', 'users', ['username'], unique=False)
|
|
||||||
op.drop_index(op.f('dashboard_l_is_defa_033b71_idx'), table_name='dashboard_layouts')
|
|
||||||
op.drop_index(op.f('dashboard_l_name_c36020_idx'), table_name='dashboard_layouts')
|
|
||||||
op.drop_index(op.f('dashboard_layouts_name_349f3640_like'), table_name='dashboard_layouts', postgresql_ops={'name': 'varchar_pattern_ops'})
|
|
||||||
op.drop_table('dashboard_layouts')
|
|
||||||
op.drop_table('django_migrations')
|
|
||||||
op.drop_table('iotDashboard_device')
|
|
||||||
op.drop_constraint(op.f('telemetry_device_id_fkey'), 'telemetry', type_='foreignkey')
|
|
||||||
op.create_foreign_key(None, 'telemetry', 'devices', ['device_id'], ['id'])
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema."""
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_constraint(None, 'telemetry', type_='foreignkey')
|
|
||||||
op.create_foreign_key(op.f('telemetry_device_id_fkey'), 'telemetry', 'devices', ['device_id'], ['id'], ondelete='CASCADE')
|
|
||||||
op.create_table('iotDashboard_device',
|
|
||||||
sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('name', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('ip', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('protocol', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('iotDashboard_device_pkey'))
|
|
||||||
)
|
|
||||||
op.create_table('django_migrations',
|
|
||||||
sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('app', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('applied', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('django_migrations_pkey'))
|
|
||||||
)
|
|
||||||
op.create_table('dashboard_layouts',
|
|
||||||
sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('is_default', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
|
||||||
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint('id', name=op.f('dashboard_layouts_pkey')),
|
|
||||||
sa.UniqueConstraint('name', name=op.f('dashboard_layouts_name_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
|
||||||
)
|
|
||||||
op.create_index(op.f('dashboard_layouts_name_349f3640_like'), 'dashboard_layouts', ['name'], unique=False, postgresql_ops={'name': 'varchar_pattern_ops'})
|
|
||||||
op.create_index(op.f('dashboard_l_name_c36020_idx'), 'dashboard_layouts', ['name'], unique=False)
|
|
||||||
op.create_index(op.f('dashboard_l_is_defa_033b71_idx'), 'dashboard_layouts', ['is_default'], unique=False)
|
|
||||||
op.drop_index('idx_users_username', table_name='users')
|
|
||||||
op.drop_index('idx_users_email', table_name='users')
|
|
||||||
op.drop_table('users')
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"""add cascade delete to telemetry foreign key
|
|
||||||
|
|
||||||
Revision ID: 1dfb0bb45f93
|
|
||||||
Revises: 7c71d43d53e3
|
|
||||||
Create Date: 2025-12-15 21:56:13.260281+00:00
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '1dfb0bb45f93'
|
|
||||||
down_revision: Union[str, Sequence[str], None] = '7c71d43d53e3'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade schema - only update telemetry foreign key."""
|
|
||||||
# Drop old foreign key constraint
|
|
||||||
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
|
|
||||||
|
|
||||||
# Add new foreign key constraint with CASCADE delete
|
|
||||||
op.create_foreign_key(
|
|
||||||
'telemetry_device_id_fkey',
|
|
||||||
'telemetry', 'devices',
|
|
||||||
['device_id'], ['id'],
|
|
||||||
ondelete='CASCADE'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade schema - revert foreign key change."""
|
|
||||||
# Drop CASCADE foreign key
|
|
||||||
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
|
|
||||||
|
|
||||||
# Add back original foreign key without CASCADE
|
|
||||||
op.create_foreign_key(
|
|
||||||
'telemetry_device_id_fkey',
|
|
||||||
'telemetry', 'devices',
|
|
||||||
['device_id'], ['id']
|
|
||||||
)
|
|
||||||
@@ -6,5 +6,7 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alembic>=1.17.0",
|
"alembic>=1.17.0",
|
||||||
|
"dotenv>=0.9.9",
|
||||||
"sqlalchemy>=2.0.44",
|
"sqlalchemy>=2.0.44",
|
||||||
|
"psycopg2-binary>=2.9.10",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ RUN npm ci
|
|||||||
|
|
||||||
COPY . .
|
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
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,19 +15,6 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
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
|
# Cache static assets
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter, Routes, Route, Link, NavLink, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Link, NavLink, Navigate } from 'react-router-dom'
|
||||||
import { Toaster } from 'react-hot-toast'
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import { useState } from 'react'
|
||||||
import { WellnessStateProvider } from './hooks/useWellnessState'
|
import { WellnessStateProvider } from './hooks/useWellnessState'
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
@@ -19,11 +20,12 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function AppLayout({ children }: { children: React.ReactNode }) {
|
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { logout } = useAuth()
|
const { logout } = useAuth()
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="drawer lg:drawer-open">
|
<div className="drawer lg:drawer-open min-h-screen">
|
||||||
<input id="main-drawer" type="checkbox" className="drawer-toggle" />
|
<input id="main-drawer" type="checkbox" className="drawer-toggle" />
|
||||||
<div className="drawer-content flex flex-col">
|
<div className="drawer-content flex flex-col min-h-screen bg-base-200">
|
||||||
{/* Navbar */}
|
{/* Navbar */}
|
||||||
<div className="navbar bg-base-300 lg:hidden">
|
<div className="navbar bg-base-300 lg:hidden">
|
||||||
<div className="flex-none">
|
<div className="flex-none">
|
||||||
@@ -44,7 +46,7 @@ function AppLayout({ children }: { children: React.ReactNode }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
<main className="flex-1 bg-base-200">
|
<main className="flex-1 min-h-full">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,48 +54,71 @@ function AppLayout({ children }: { children: React.ReactNode }) {
|
|||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="drawer-side">
|
<div className="drawer-side">
|
||||||
<label htmlFor="main-drawer" className="drawer-overlay"></label>
|
<label htmlFor="main-drawer" className="drawer-overlay"></label>
|
||||||
<aside className="bg-base-100 w-64 min-h-full flex flex-col">
|
<aside className={`bg-base-100 min-h-screen flex flex-col transition-all duration-300 ${sidebarCollapsed ? 'w-16' : 'w-56'}`}>
|
||||||
<div className="p-4">
|
{/* Toggle button (desktop only) */}
|
||||||
<Link to="/" className="flex items-center gap-2 text-2xl font-bold">
|
<div className="hidden lg:flex justify-end p-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<button
|
||||||
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
className="btn btn-ghost btn-xs btn-square"
|
||||||
|
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${sidebarCollapsed ? 'px-2' : 'p-4'}`}>
|
||||||
|
<Link to="/" className={`flex items-center gap-2 font-bold ${sidebarCollapsed ? 'justify-center' : 'text-2xl'}`}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className={`${sidebarCollapsed ? 'h-6 w-6' : 'h-8 w-8'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
<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>
|
</svg>
|
||||||
<span>Lyncis</span>
|
{!sidebarCollapsed && <span>Lyncis</span>}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="menu p-4 space-y-2">
|
<ul className={`menu space-y-2 ${sidebarCollapsed ? 'p-2' : 'p-4'}`}>
|
||||||
<li>
|
<li>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/"
|
to="/"
|
||||||
className={({ isActive }) => isActive ? 'active' : ''}
|
className={({ isActive }) => `${isActive ? 'active' : ''} ${sidebarCollapsed ? 'tooltip tooltip-right' : ''}`}
|
||||||
|
data-tip="Dashboard"
|
||||||
end
|
end
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
Dashboard
|
{!sidebarCollapsed && 'Dashboard'}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/devices"
|
to="/devices"
|
||||||
className={({ isActive }) => isActive ? 'active' : ''}
|
className={({ isActive }) => `${isActive ? 'active' : ''} ${sidebarCollapsed ? 'tooltip tooltip-right' : ''}`}
|
||||||
|
data-tip="Devices"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
Devices
|
{!sidebarCollapsed && 'Devices'}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="mt-auto p-4">
|
<div className={`mt-auto ${sidebarCollapsed ? 'p-2' : 'p-4'}`}>
|
||||||
<button onClick={logout} className="btn btn-ghost btn-sm w-full">
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className={`btn btn-ghost btn-sm w-full ${sidebarCollapsed ? 'tooltip tooltip-right' : ''}`}
|
||||||
|
data-tip="Logout"
|
||||||
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
</svg>
|
</svg>
|
||||||
Logout
|
{!sidebarCollapsed && 'Logout'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export const telemetryApi = {
|
|||||||
analyze: (data: {
|
analyze: (data: {
|
||||||
device_id: string;
|
device_id: string;
|
||||||
metric?: string;
|
metric?: string;
|
||||||
|
metrics?: string[]; // Support multiple metrics
|
||||||
hours?: number;
|
hours?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
prompt_type?: 'anomaly_detection' | 'trend_summary' | 'custom';
|
prompt_type?: 'anomaly_detection' | 'trend_summary' | 'custom';
|
||||||
|
|||||||
@@ -86,6 +86,87 @@ export default function CredentialsViewer({ credentials, deviceId }: Credentials
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* MQTT Connection Information */}
|
||||||
|
<div className="rounded-lg bg-info/10 border border-info/30 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-info shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold mb-2">Connection Configuration</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium">MQTT Broker:</span>
|
||||||
|
<code className="bg-base-100 px-2 py-1 rounded text-xs">{mqttBroker}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium">Port (TLS):</span>
|
||||||
|
<code className="bg-base-100 px-2 py-1 rounded text-xs">{mqttPort}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium">Device ID:</span>
|
||||||
|
<code className="bg-base-100 px-2 py-1 rounded text-xs">{resolvedDeviceId}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Publishing Guide */}
|
||||||
|
<div className="rounded-lg bg-primary/10 border border-primary/30 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-primary shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold mb-2">How to Publish Data</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium block mb-1">Topic Format:</span>
|
||||||
|
<code className="block bg-base-100 px-3 py-2 rounded text-xs break-all">
|
||||||
|
devices/{resolvedDeviceId}/{'<metric>'}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="font-medium block mb-1">Payload:</span>
|
||||||
|
<p className="opacity-80 text-xs mb-1">Send numeric value as plain text (no JSON)</p>
|
||||||
|
<code className="block bg-base-100 px-3 py-2 rounded text-xs">23.5</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="font-medium block mb-1">Supported Metrics:</span>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{['temperature', 'humidity', 'co2', 'pm25', 'pm10', 'pressure', 'light', 'noise'].map(metric => (
|
||||||
|
<span key={metric} className="badge badge-sm badge-outline">{metric}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider my-2"></div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="font-medium block mb-1">Example (mosquitto_pub):</span>
|
||||||
|
<code className="block bg-base-100 px-3 py-2 rounded text-xs overflow-x-auto">
|
||||||
|
mosquitto_pub --cafile ca.crt --cert {resolvedDeviceId}.crt --key {resolvedDeviceId}.key \<br/>
|
||||||
|
-h {mqttBroker} -p {mqttPort} \<br/>
|
||||||
|
-t "devices/{resolvedDeviceId}/temperature" -m "23.5"
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-outline mt-2"
|
||||||
|
onClick={() => copyToClipboard(
|
||||||
|
`mosquitto_pub --cafile ca.crt --cert ${resolvedDeviceId}.crt --key ${resolvedDeviceId}.key -h ${mqttBroker} -p ${mqttPort} -t "devices/${resolvedDeviceId}/temperature" -m "23.5"`,
|
||||||
|
'Example command'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Copy Command
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{(credentials.certificate_id || expiresAt) && (
|
{(credentials.certificate_id || expiresAt) && (
|
||||||
<div className="rounded-lg bg-base-200 p-4 text-sm">
|
<div className="rounded-lg bg-base-200 p-4 text-sm">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
|||||||
const [showAnalysis, setShowAnalysis] = useState(false)
|
const [showAnalysis, setShowAnalysis] = useState(false)
|
||||||
|
|
||||||
const deviceId = deviceIds[0]
|
const deviceId = deviceIds[0]
|
||||||
const metric = metricIds[0]
|
const metrics = metricIds // Use ALL selected metrics
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: analysis,
|
data: analysis,
|
||||||
@@ -53,11 +53,11 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
|||||||
refetch,
|
refetch,
|
||||||
error,
|
error,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['ai-insight', deviceId, metric, promptType, timeframe],
|
queryKey: ['ai-insight', deviceId, metrics, promptType, timeframe],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await telemetryApi.analyze({
|
const response = await telemetryApi.analyze({
|
||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
metric,
|
metrics: metrics, // Send all metrics
|
||||||
hours: timeframe.hours || 240,
|
hours: timeframe.hours || 240,
|
||||||
limit: 200,
|
limit: 200,
|
||||||
prompt_type: promptType,
|
prompt_type: promptType,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
|||||||
const response = await weatherApi.getAirQuality(city)
|
const response = await weatherApi.getAirQuality(city)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
refetchInterval: 300000, // Refresh every 5 minutes
|
refetchInterval: 5000, // Refresh every 5 seconds
|
||||||
staleTime: 240000, // Consider fresh for 4 minutes
|
staleTime: 240000, // Consider fresh for 4 minutes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
|||||||
const response = await devicesApi.getComfortIndex(deviceId)
|
const response = await devicesApi.getComfortIndex(deviceId)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
refetchInterval: 60000, // Refresh every minute
|
refetchInterval: 5000, // Refresh every 5 seconds
|
||||||
enabled: !!deviceId,
|
enabled: !!deviceId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function HealthStatsWidget({ config }: HealthStatsWidgetProps) {
|
|||||||
const response = await wellnessApi.getHealthInsights(deviceId, widgetCity || undefined)
|
const response = await wellnessApi.getHealthInsights(deviceId, widgetCity || undefined)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
refetchInterval: 60000, // Refresh every minute
|
refetchInterval: 5000, // Refresh every 5 seconds
|
||||||
enabled: !!deviceId,
|
enabled: !!deviceId,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function RunSuitabilityWidget({ config }: RunSuitabilityWidgetPro
|
|||||||
const response = await wellnessApi.getRunSuitability(deviceId, widgetCity)
|
const response = await wellnessApi.getRunSuitability(deviceId, widgetCity)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
refetchInterval: 300000, // Refresh every 5 minutes
|
refetchInterval: 5000, // Refresh every 5 seconds
|
||||||
enabled: !!deviceId && !!widgetCity,
|
enabled: !!deviceId && !!widgetCity,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
|||||||
const response = await weatherApi.getCurrent({ city })
|
const response = await weatherApi.getCurrent({ city })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
refetchInterval: 300000, // Refresh every 5 minutes
|
refetchInterval: 5000, // Refresh every 5 seconds
|
||||||
staleTime: 240000, // Consider fresh for 4 minutes
|
staleTime: 240000, // Consider fresh for 4 minutes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -145,16 +145,41 @@ export function useDashboardConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only save to localStorage automatically (no backend saves)
|
// Auto-save to localStorage and debounced backend save
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialLoadRef.current) {
|
if (!isInitialLoadRef.current) {
|
||||||
|
// Save to localStorage immediately
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save to localStorage:', 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) => {
|
const addWidget = (widget: WidgetConfig) => {
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ export function useTelemetrySeries({
|
|||||||
return response.data as Telemetry[]
|
return response.data as Telemetry[]
|
||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
refetchInterval: enabled ? 60000 : false, // Refresh every 60 seconds (reduced frequency)
|
refetchInterval: enabled ? 5000 : false, // Refresh every 5 seconds
|
||||||
staleTime: 30000, // Consider data fresh for 30 seconds
|
staleTime: 3000, // Consider data fresh for 3 seconds
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
--dui-themes: light, dark, cupcake, corporate;
|
--dui-themes: light, dark, cupcake, corporate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure full viewport height */
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom scrollbar styles */
|
/* Custom scrollbar styles */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6 min-h-screen">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||||
@@ -153,8 +153,14 @@ export default function Dashboard() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="text-sm text-base-content/60">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Auto-save enabled
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn btn-success btn-sm"
|
className="btn btn-outline btn-sm"
|
||||||
onClick={handleSaveDashboard}
|
onClick={handleSaveDashboard}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
@@ -168,7 +174,7 @@ export default function Dashboard() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
Save Dashboard
|
Save Now
|
||||||
</button>
|
</button>
|
||||||
{saveStatus === 'success' && (
|
{saveStatus === 'success' && (
|
||||||
<span className="text-success text-sm">Saved!</span>
|
<span className="text-success text-sm">Saved!</span>
|
||||||
|
|||||||
129
infrastructure/compose.prod.yml
Normal file
129
infrastructure/compose.prod.yml
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mosquitto:
|
||||||
|
image: eclipse-mosquitto:2.0
|
||||||
|
ports:
|
||||||
|
- "1883:1883"
|
||||||
|
- "9001:9001"
|
||||||
|
- "8883:8883"
|
||||||
|
volumes:
|
||||||
|
- ./mosquitto/:/mosquitto/:Z
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
timescaledb:
|
||||||
|
image: timescale/timescaledb:latest-pg17
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-example}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-iot_data}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- timescaledb-data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
device-manager:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/device-manager:latest
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-example}@timescaledb:5432/${POSTGRES_DB:-iot_data}
|
||||||
|
- CA_CERT_PATH=/app/certs/ca.crt
|
||||||
|
- CA_KEY_PATH=/app/certs/ca.key
|
||||||
|
- CRL_PATH=/app/certs/ca.crl
|
||||||
|
volumes:
|
||||||
|
- ./mosquitto/certs:/app/certs:z
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mqtt-ingestion:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/mqtt-ingestion:latest
|
||||||
|
environment:
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- REDIS_DB=0
|
||||||
|
- MQTT_BROKER=mosquitto
|
||||||
|
- MQTT_PORT=1883
|
||||||
|
- MQTT_USER=${MQTT_USER:-}
|
||||||
|
- MQTT_PASS=${MQTT_PASS:-}
|
||||||
|
depends_on:
|
||||||
|
- mosquitto
|
||||||
|
- redis
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db-write:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/db-write:latest
|
||||||
|
environment:
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- REDIS_DB=0
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-example}@timescaledb:5432/${POSTGRES_DB:-iot_data}
|
||||||
|
- CONSUMER_GROUP_NAME=db_writer
|
||||||
|
- CONSUMER_NAME=worker-01
|
||||||
|
- BATCH_SIZE=100
|
||||||
|
- BATCH_TIMEOUT_SEC=5
|
||||||
|
- STREAM_PATTERN=mqtt_stream:*
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
mqtt-ingestion:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/backend:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_HOST=timescaledb
|
||||||
|
- POSTGRES_PORT=5432
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-example}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB:-iot_data}
|
||||||
|
- DEVICE_MANAGER_URL=http://device-manager:8000
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||||
|
- DEBUG=False
|
||||||
|
- ALLOWED_HOSTS=*
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
device-manager:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/frontend:latest
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
gpt-service:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis-gpt-service:latest
|
||||||
|
environment:
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
timescaledb-data:
|
||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
- "8883:8883"
|
- "8883:8883"
|
||||||
volumes:
|
volumes:
|
||||||
- ./mosquitto/:/mosquitto/:Z
|
- ./mosquitto/:/mosquitto/
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
timescaledb:
|
timescaledb:
|
||||||
@@ -29,19 +29,26 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- timescaledb-data:/var/lib/postgresql/data
|
- timescaledb-data:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
db-write:
|
device-manager:
|
||||||
build:
|
build:
|
||||||
context: ../services/db_write
|
context: ../services/device_manager
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env.dbw
|
- ../services/device_manager/.env
|
||||||
|
volumes:
|
||||||
|
- ./mosquitto/certs:/app/certs:z
|
||||||
depends_on:
|
depends_on:
|
||||||
- timescaledb
|
timescaledb:
|
||||||
- redis
|
condition: service_healthy
|
||||||
- mqtt-ingestion
|
restart: unless-stopped
|
||||||
restart:
|
|
||||||
unless-stopped
|
|
||||||
|
|
||||||
mqtt-ingestion:
|
mqtt-ingestion:
|
||||||
build:
|
build:
|
||||||
context: ../services/mqtt_ingestion
|
context: ../services/mqtt_ingestion
|
||||||
@@ -50,18 +57,66 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- mosquitto
|
- mosquitto
|
||||||
- redis
|
- redis
|
||||||
restart:
|
restart: unless-stopped
|
||||||
unless-stopped
|
|
||||||
|
|
||||||
# gpt-service:
|
db-write:
|
||||||
# build:
|
build:
|
||||||
# context: ./services/gpt_service
|
context: ../services/db_write
|
||||||
# ports:
|
env_file:
|
||||||
# - "8001:8001"
|
- .env.dbw
|
||||||
# restart: unless-stopped
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
mqtt-ingestion:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/backend:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_HOST=timescaledb
|
||||||
|
- POSTGRES_PORT=5432
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-example}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB:-iot_data}
|
||||||
|
- DEVICE_MANAGER_URL=http://device-manager:8000
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}6
|
||||||
|
- DEBUG=False
|
||||||
|
- ALLOWED_HOSTS=*
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
device-manager:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ../frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: /api
|
||||||
|
VITE_DEVICE_MANAGER_URL: ${VITE_DEVICE_MANAGER_URL:-http://localhost/api}
|
||||||
|
VITE_MQTT_BROKER: ${VITE_MQTT_BROKER:-localhost}
|
||||||
|
VITE_MQTT_PORT: ${VITE_MQTT_PORT:-8883}
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- django
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
gpt-service:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis-gpt-service:latest
|
||||||
|
environment:
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
|||||||
@@ -408,7 +408,14 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
time__gte=timezone.now() - timedelta(hours=hours)
|
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)
|
queryset = queryset.filter(metric=metric)
|
||||||
|
|
||||||
telemetry = queryset.order_by('-time')[:limit]
|
telemetry = queryset.order_by('-time')[:limit]
|
||||||
|
|||||||
@@ -211,20 +211,36 @@ class RunSuitabilityCalculator:
|
|||||||
current_hour = current_time.hour
|
current_hour = current_time.hour
|
||||||
recommendations = []
|
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:
|
for start_time, end_time, description in RunSuitabilityCalculator.BEST_TIMES:
|
||||||
if start_time.hour <= current_hour < end_time.hour:
|
if start_time.hour <= current_hour < end_time.hour:
|
||||||
recommendations.append(f"Current time is ideal: {description}")
|
recommendations.append(f"Current time is ideal: {description}")
|
||||||
else:
|
in_optimal_time = True
|
||||||
# Calculate hours until next optimal time
|
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:
|
if current_hour < start_time.hour:
|
||||||
|
# This window is later today
|
||||||
hours_until = start_time.hour - current_hour
|
hours_until = start_time.hour - current_hour
|
||||||
recommendations.append(f"Best time in {hours_until} hours ({start_time.strftime('%I:%M %p')}): {description}")
|
next_window = (hours_until, start_time, description, "today")
|
||||||
elif current_hour >= end_time.hour:
|
break
|
||||||
# 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]
|
# If no window found later today, next window is tomorrow morning
|
||||||
hours_until = (24 - current_hour) + next_start.hour
|
if next_window is None:
|
||||||
recommendations.append(f"Best time in {hours_until} hours ({next_start.strftime('%I:%M %p')}): {description}")
|
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
|
return recommendations
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ CORS_ALLOWED_ORIGINS = [
|
|||||||
"http://127.0.0.1:5173",
|
"http://127.0.0.1:5173",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
"http://127.0.0.1:3000",
|
"http://127.0.0.1:3000",
|
||||||
|
"https://lyncis.ferdzo.xyz",
|
||||||
|
"http://lyncis.ferdzo.xyz",
|
||||||
]
|
]
|
||||||
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|||||||
@@ -30,24 +30,16 @@ urlpatterns = [
|
|||||||
# REST API
|
# REST API
|
||||||
path("api/", include("iotDashboard.api.urls")),
|
path("api/", include("iotDashboard.api.urls")),
|
||||||
|
|
||||||
# Main dashboard
|
# Legacy template views - DISABLED (using React frontend)
|
||||||
path("", views.chart, name="index"),
|
# path("", views.chart, name="index"),
|
||||||
path("chart/", views.chart, name="chart"),
|
# path("chart/", views.chart, name="chart"),
|
||||||
|
# path("devices/", views.device_list, name="device_list"),
|
||||||
# Device management
|
# path("devices/add/", views.add_device, name="add_device"),
|
||||||
path("devices/", views.device_list, name="device_list"),
|
# path("devices/<str:device_id>/", views.view_device, name="view_device"),
|
||||||
path("devices/add/", views.add_device, name="add_device"),
|
# path("devices/<str:device_id>/delete/", views.delete_device, name="delete_device"),
|
||||||
path("devices/<str:device_id>/", views.view_device, name="view_device"),
|
# path("devices/<str:device_id>/certificate/revoke/", views.revoke_certificate, name="revoke_certificate"),
|
||||||
path("devices/<str:device_id>/delete/", views.delete_device, name="delete_device"),
|
# path("devices/<str:device_id>/certificate/renew/", views.renew_certificate, name="renew_certificate"),
|
||||||
|
# path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"),
|
||||||
# Certificate management (MQTT devices only)
|
# path("devices_api/", views.devices_api, name="devices_api"),
|
||||||
path("devices/<str:device_id>/certificate/revoke/", views.revoke_certificate, name="revoke_certificate"),
|
# path("logout/", views.logout_view, name="logout"),
|
||||||
path("devices/<str:device_id>/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"),
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ dependencies = [
|
|||||||
"python-dotenv>=1.2.1",
|
"python-dotenv>=1.2.1",
|
||||||
"redis>=7.0.1",
|
"redis>=7.0.1",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
|
"uvicorn>=0.38.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
17
uv.lock
generated
17
uv.lock
generated
@@ -146,12 +146,16 @@ version = "0.1.0"
|
|||||||
source = { virtual = "db_migrations" }
|
source = { virtual = "db_migrations" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
{ name = "dotenv" },
|
||||||
|
{ name = "psycopg2-binary" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alembic", specifier = ">=1.17.0" },
|
{ 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" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.121.0"
|
version = "0.121.0"
|
||||||
@@ -376,6 +391,7 @@ dependencies = [
|
|||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -397,6 +413,7 @@ requires-dist = [
|
|||||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||||
{ name = "redis", specifier = ">=7.0.1" },
|
{ name = "redis", specifier = ">=7.0.1" },
|
||||||
{ name = "requests", specifier = ">=2.32.5" },
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
|
{ name = "uvicorn", specifier = ">=0.38.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
|
|||||||
Reference in New Issue
Block a user