3 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
d1eb102e44 Fix code review issues: remove unused imports and fix exception handling
Co-authored-by: ferdzo <6640598+ferdzo@users.noreply.github.com>
2026-02-11 21:43:37 +00:00
copilot-swe-agent[bot]
8fa4117ce0 Initial plan 2026-02-11 21:41:20 +00:00
4ce795228d Updates and fixes. 2025-12-19 11:26:26 +01:00
32 changed files with 463 additions and 237 deletions

View File

@@ -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"]

View File

@@ -26,7 +26,7 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'iotDashboard.settings')
# Import Django settings and configure password hasher manually # Import Django settings and configure password hasher manually
from django.conf import settings from django.conf import settings
if not settings.configured: if not settings.configured:
from iotDashboard.settings import * from iotDashboard.settings import SECRET_KEY
settings.configure( settings.configure(
PASSWORD_HASHERS=[ PASSWORD_HASHERS=[
'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2PasswordHasher',

View File

@@ -4,7 +4,7 @@ WORKDIR /app
COPY pyproject.toml ./ COPY pyproject.toml ./
RUN uv sync RUN uv sync --no-cache
COPY . . COPY . .

View File

@@ -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 ###

View File

@@ -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']
)

View File

@@ -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",
] ]

View File

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

View File

@@ -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;

View File

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

View File

@@ -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';

View File

@@ -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/>
&nbsp;&nbsp;-h {mqttBroker} -p {mqttPort} \<br/>
&nbsp;&nbsp;-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">

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

@@ -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 {

View File

@@ -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;

View File

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

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

View File

@@ -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:

View File

@@ -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]

View File

@@ -10,7 +10,6 @@ PostgreSQL is chosen over Redis because:
""" """
from django.db import models from django.db import models
from django.utils import timezone
class DashboardLayout(models.Model): class DashboardLayout(models.Model):

View File

@@ -7,7 +7,7 @@ Shows how weather and air quality affect health metrics.
from typing import Dict, List, Optional from typing import Dict, List, Optional
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime
@dataclass @dataclass

View File

@@ -5,7 +5,7 @@ Combines weather, air quality, and health data to determine if conditions are su
Provides time-based recommendations and personalized insights. Provides time-based recommendations and personalized insights.
""" """
from typing import Dict, List, Optional, Tuple from typing import List, Optional, Tuple
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, time from datetime import datetime, time
@@ -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

View File

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

View File

@@ -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"),
] ]

View File

@@ -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]

View File

@@ -405,7 +405,7 @@ Keep all text concise: summary under 50 words, each item under 20 words.""",
try: try:
dt = datetime.fromisoformat(current_time.replace('Z', '+00:00')) dt = datetime.fromisoformat(current_time.replace('Z', '+00:00'))
time_str = dt.strftime("%A, %B %d at %I:%M %p") time_str = dt.strftime("%A, %B %d at %I:%M %p")
except: except (ValueError, AttributeError):
time_str = current_time time_str = current_time
context_sections.append(f"Current Time: {time_str}") context_sections.append(f"Current Time: {time_str}")

17
uv.lock generated
View File

@@ -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]