mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 09:06:26 +00:00
Updates and fixes.
This commit is contained in:
@@ -8,6 +8,16 @@ RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_URL=/api
|
||||
ARG VITE_DEVICE_MANAGER_URL
|
||||
ARG VITE_MQTT_BROKER
|
||||
ARG VITE_MQTT_PORT=8883
|
||||
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_DEVICE_MANAGER_URL=$VITE_DEVICE_MANAGER_URL
|
||||
ENV VITE_MQTT_BROKER=$VITE_MQTT_BROKER
|
||||
ENV VITE_MQTT_PORT=$VITE_MQTT_PORT
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
|
||||
@@ -15,19 +15,6 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to Django backend
|
||||
location /api/ {
|
||||
proxy_pass http://django:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter, Routes, Route, Link, NavLink, Navigate } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { useState } from 'react'
|
||||
import { WellnessStateProvider } from './hooks/useWellnessState'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
@@ -19,11 +20,12 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const { logout } = useAuth()
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="drawer lg:drawer-open">
|
||||
<div className="drawer lg:drawer-open min-h-screen">
|
||||
<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 */}
|
||||
<div className="navbar bg-base-300 lg:hidden">
|
||||
<div className="flex-none">
|
||||
@@ -44,7 +46,7 @@ function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 bg-base-200">
|
||||
<main className="flex-1 min-h-full">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
@@ -52,48 +54,71 @@ function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
{/* Sidebar */}
|
||||
<div className="drawer-side">
|
||||
<label htmlFor="main-drawer" className="drawer-overlay"></label>
|
||||
<aside className="bg-base-100 w-64 min-h-full flex flex-col">
|
||||
<div className="p-4">
|
||||
<Link to="/" className="flex items-center gap-2 text-2xl font-bold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<aside className={`bg-base-100 min-h-screen flex flex-col transition-all duration-300 ${sidebarCollapsed ? 'w-16' : 'w-56'}`}>
|
||||
{/* Toggle button (desktop only) */}
|
||||
<div className="hidden lg:flex justify-end p-2">
|
||||
<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" />
|
||||
</svg>
|
||||
<span>Lyncis</span>
|
||||
{!sidebarCollapsed && <span>Lyncis</span>}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="menu p-4 space-y-2">
|
||||
<ul className={`menu space-y-2 ${sidebarCollapsed ? 'p-2' : 'p-4'}`}>
|
||||
<li>
|
||||
<NavLink
|
||||
to="/"
|
||||
className={({ isActive }) => isActive ? 'active' : ''}
|
||||
className={({ isActive }) => `${isActive ? 'active' : ''} ${sidebarCollapsed ? 'tooltip tooltip-right' : ''}`}
|
||||
data-tip="Dashboard"
|
||||
end
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Dashboard
|
||||
{!sidebarCollapsed && 'Dashboard'}
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
Devices
|
||||
{!sidebarCollapsed && 'Devices'}
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto p-4">
|
||||
<button onClick={logout} className="btn btn-ghost btn-sm w-full">
|
||||
<div className={`mt-auto ${sidebarCollapsed ? 'p-2' : 'p-4'}`}>
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Logout
|
||||
{!sidebarCollapsed && 'Logout'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -97,6 +97,7 @@ export const telemetryApi = {
|
||||
analyze: (data: {
|
||||
device_id: string;
|
||||
metric?: string;
|
||||
metrics?: string[]; // Support multiple metrics
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
prompt_type?: 'anomaly_detection' | 'trend_summary' | 'custom';
|
||||
|
||||
@@ -86,6 +86,87 @@ export default function CredentialsViewer({ credentials, deviceId }: Credentials
|
||||
</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) && (
|
||||
<div className="rounded-lg bg-base-200 p-4 text-sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||
const [showAnalysis, setShowAnalysis] = useState(false)
|
||||
|
||||
const deviceId = deviceIds[0]
|
||||
const metric = metricIds[0]
|
||||
const metrics = metricIds // Use ALL selected metrics
|
||||
|
||||
const {
|
||||
data: analysis,
|
||||
@@ -53,11 +53,11 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||
refetch,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['ai-insight', deviceId, metric, promptType, timeframe],
|
||||
queryKey: ['ai-insight', deviceId, metrics, promptType, timeframe],
|
||||
queryFn: async () => {
|
||||
const response = await telemetryApi.analyze({
|
||||
device_id: deviceId,
|
||||
metric,
|
||||
metrics: metrics, // Send all metrics
|
||||
hours: timeframe.hours || 240,
|
||||
limit: 200,
|
||||
prompt_type: promptType,
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
||||
const response = await weatherApi.getAirQuality(city)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 300000, // Refresh every 5 minutes
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
staleTime: 240000, // Consider fresh for 4 minutes
|
||||
})
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
const response = await devicesApi.getComfortIndex(deviceId)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
enabled: !!deviceId,
|
||||
})
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function HealthStatsWidget({ config }: HealthStatsWidgetProps) {
|
||||
const response = await wellnessApi.getHealthInsights(deviceId, widgetCity || undefined)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
enabled: !!deviceId,
|
||||
})
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function RunSuitabilityWidget({ config }: RunSuitabilityWidgetPro
|
||||
const response = await wellnessApi.getRunSuitability(deviceId, widgetCity)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 300000, // Refresh every 5 minutes
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
enabled: !!deviceId && !!widgetCity,
|
||||
})
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
const response = await weatherApi.getCurrent({ city })
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 300000, // Refresh every 5 minutes
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
staleTime: 240000, // Consider fresh for 4 minutes
|
||||
})
|
||||
|
||||
|
||||
@@ -145,16 +145,41 @@ export function useDashboardConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only save to localStorage automatically (no backend saves)
|
||||
// Auto-save to localStorage and debounced backend save
|
||||
useEffect(() => {
|
||||
if (!isInitialLoadRef.current) {
|
||||
// Save to localStorage immediately
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error)
|
||||
}
|
||||
|
||||
// Auto-save to backend with debounce (2 seconds after last change)
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
if (layoutId) {
|
||||
await dashboardLayoutApi.update(layoutId, {
|
||||
config: config,
|
||||
})
|
||||
console.log('Dashboard auto-saved to backend')
|
||||
} else {
|
||||
const response = await dashboardLayoutApi.create({
|
||||
name: 'default',
|
||||
config: config,
|
||||
is_default: true,
|
||||
})
|
||||
setLayoutId(response.data.id)
|
||||
console.log('Dashboard created and auto-saved to backend')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-save to backend:', error)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [config])
|
||||
}, [config, layoutId])
|
||||
|
||||
|
||||
const addWidget = (widget: WidgetConfig) => {
|
||||
|
||||
@@ -62,8 +62,8 @@ export function useTelemetrySeries({
|
||||
return response.data as Telemetry[]
|
||||
},
|
||||
enabled,
|
||||
refetchInterval: enabled ? 60000 : false, // Refresh every 60 seconds (reduced frequency)
|
||||
staleTime: 30000, // Consider data fresh for 30 seconds
|
||||
refetchInterval: enabled ? 5000 : false, // Refresh every 5 seconds
|
||||
staleTime: 3000, // Consider data fresh for 3 seconds
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
--dui-themes: light, dark, cupcake, corporate;
|
||||
}
|
||||
|
||||
/* Ensure full viewport height */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -144,7 +144,7 @@ export default function Dashboard() {
|
||||
}
|
||||
|
||||
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>
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
@@ -153,8 +153,14 @@ export default function Dashboard() {
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
className="btn btn-success btn-sm"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={handleSaveDashboard}
|
||||
disabled={isSaving}
|
||||
>
|
||||
@@ -168,7 +174,7 @@ export default function Dashboard() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
Save Dashboard
|
||||
Save Now
|
||||
</button>
|
||||
{saveStatus === 'success' && (
|
||||
<span className="text-success text-sm">Saved!</span>
|
||||
|
||||
Reference in New Issue
Block a user