mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 09:06:26 +00:00
Introduced air quality and weather, onboarding for mobile devices with qr code and otp. Cascade on delete of device with telemtry.
This commit is contained in:
8
frontend/.env.example
Normal file
8
frontend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# API Configuration
|
||||
VITE_API_URL=/api
|
||||
# Django proxy URL (used for QR code API endpoint)
|
||||
VITE_DEVICE_MANAGER_URL=http://10.10.1.114:3000/api
|
||||
|
||||
# MQTT Configuration
|
||||
VITE_MQTT_BROKER=10.10.1.114
|
||||
VITE_MQTT_PORT=8883
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
21
frontend/package-lock.json
generated
21
frontend/package-lock.json
generated
@@ -13,11 +13,11 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@tanstack/react-query": "^5.62.8",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-grid-layout": "^1.5.2",
|
||||
@@ -2520,6 +2520,7 @@
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
@@ -2535,15 +2536,6 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-grid-layout": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz",
|
||||
"integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz",
|
||||
@@ -4921,6 +4913,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
||||
@@ -10,42 +10,19 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@tanstack/react-query": "^5.62.8",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-grid-layout": "^1.5.2",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router": "^7.1.0",
|
||||
"react-router-dom": "^7.9.5",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^5.0.2"
|
||||
"react-dom": "^19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"daisyui": "^5.4.2",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.22",
|
||||
"globals": "^16.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vite": "^7.1.7"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -17,7 +17,24 @@ interface PaginatedResponse<T> {
|
||||
|
||||
// Device API
|
||||
export const devicesApi = {
|
||||
getAll: () => apiClient.get<PaginatedResponse<Device>>('/devices/'),
|
||||
getAll: async () => {
|
||||
const response = await apiClient.get<Device[] | PaginatedResponse<Device>>('/devices/');
|
||||
// Handle both paginated and non-paginated responses
|
||||
if (Array.isArray(response.data)) {
|
||||
// Non-paginated response - wrap it
|
||||
return {
|
||||
...response,
|
||||
data: {
|
||||
count: response.data.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: response.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Already paginated
|
||||
return response as typeof response & { data: PaginatedResponse<Device> };
|
||||
},
|
||||
|
||||
getOne: (id: string) => apiClient.get<Device>(`/devices/${id}/`),
|
||||
|
||||
@@ -78,3 +95,37 @@ export const telemetryApi = {
|
||||
export const dashboardApi = {
|
||||
getOverview: () => apiClient.get<DashboardOverview>('/dashboard/overview/'),
|
||||
};
|
||||
|
||||
// Weather API
|
||||
export const weatherApi = {
|
||||
getCurrent: (params: { city?: string; lat?: number; lon?: number }) =>
|
||||
apiClient.get<{
|
||||
location: string;
|
||||
temperature: number;
|
||||
apparent_temperature: number;
|
||||
humidity: number;
|
||||
weather_description: string;
|
||||
weather_code: number;
|
||||
precipitation: number;
|
||||
rain: number;
|
||||
cloud_cover: number;
|
||||
wind_speed: number;
|
||||
wind_direction: number;
|
||||
time: string;
|
||||
timezone: string;
|
||||
}>('/weather/current/', { params }),
|
||||
|
||||
getAirQuality: (city: string) =>
|
||||
apiClient.get<{
|
||||
city: string;
|
||||
measurements: Record<string, {
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
}>;
|
||||
status: string;
|
||||
timestamp: string;
|
||||
sensor_count: number;
|
||||
}>('/weather/air_quality/', { params: { city } }),
|
||||
};
|
||||
|
||||
@@ -17,8 +17,9 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||
const [timeframeHours, setTimeframeHours] = useState(24)
|
||||
const [widgetWidth, setWidgetWidth] = useState(1) // Default to 1 column (small)
|
||||
const [widgetHeight, setWidgetHeight] = useState(2) // Default to 2 rows (medium)
|
||||
const [widgetWidth, setWidgetWidth] = useState(1)
|
||||
const [widgetHeight, setWidgetHeight] = useState(2)
|
||||
const [city, setCity] = useState('Skopje')
|
||||
|
||||
// Fetch devices
|
||||
const { data: devicesData } = useQuery({
|
||||
@@ -63,20 +64,31 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
}, [selectedDevices])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
|
||||
alert('Please select at least one device and one metric')
|
||||
return
|
||||
// Weather and air-quality widgets don't need device/metric validation
|
||||
if (widgetType !== 'weather' && widgetType !== 'air-quality') {
|
||||
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
|
||||
alert('Please select at least one device and one metric')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
|
||||
const defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
|
||||
// Create title
|
||||
let defaultTitle = ''
|
||||
if (widgetType === 'weather') {
|
||||
defaultTitle = `Weather - ${city}`
|
||||
} else if (widgetType === 'air-quality') {
|
||||
defaultTitle = `Air Quality - ${city}`
|
||||
} else {
|
||||
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
|
||||
defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
|
||||
}
|
||||
|
||||
const newWidget: WidgetConfig = {
|
||||
id: `widget-${Date.now()}`,
|
||||
type: widgetType,
|
||||
title: title || defaultTitle,
|
||||
deviceIds: selectedDevices,
|
||||
metricIds: selectedMetrics,
|
||||
deviceIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedDevices,
|
||||
metricIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedMetrics,
|
||||
timeframe: {
|
||||
hours: timeframeHours,
|
||||
},
|
||||
@@ -84,6 +96,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
showLegend: true,
|
||||
showGrid: true,
|
||||
height: widgetType === 'line-chart' ? 300 : undefined,
|
||||
city: widgetType === 'weather' || widgetType === 'air-quality' ? city : undefined,
|
||||
},
|
||||
position: {
|
||||
x: 0,
|
||||
@@ -197,6 +210,32 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
<div className="text-xs opacity-70">GPT analysis</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'weather' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('weather')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Weather</div>
|
||||
<div className="text-xs opacity-70">Open-Meteo</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'air-quality' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('air-quality')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Air Quality</div>
|
||||
<div className="text-xs opacity-70">Pulse.eco</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,7 +243,17 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
<button className="btn btn-ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => setStep(2)}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
// Skip data source step for weather and air quality widgets
|
||||
if (widgetType === 'weather' || widgetType === 'air-quality') {
|
||||
setStep(3)
|
||||
} else {
|
||||
setStep(2)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
@@ -212,7 +261,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
)}
|
||||
|
||||
{/* Step 2: Data Source */}
|
||||
{step === 2 && (
|
||||
{step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && (
|
||||
<div className="space-y-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
@@ -304,35 +353,76 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
{/* Step 3: Configure */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Auto-generated if empty"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* City input for weather and air-quality widgets */}
|
||||
{(widgetType === 'weather' || widgetType === 'air-quality') ? (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">City</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Enter city name (e.g., Skopje)"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
/>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">
|
||||
{widgetType === 'air-quality'
|
||||
? 'Available cities: Skopje, Bitola, Veles, Tetovo, etc.'
|
||||
: 'Enter any city name for weather data'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Time Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={timeframeHours}
|
||||
onChange={(e) => setTimeframeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>Last 1 hour</option>
|
||||
<option value={6}>Last 6 hours</option>
|
||||
<option value={24}>Last 24 hours</option>
|
||||
<option value={168}>Last 7 days</option>
|
||||
<option value={720}>Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder={widgetType === 'weather' ? `Weather - ${city}` : `Air Quality - ${city}`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Original configuration for sensor-based widgets
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Auto-generated if empty"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Time Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={timeframeHours}
|
||||
onChange={(e) => setTimeframeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>Last 1 hour</option>
|
||||
<option value={6}>Last 6 hours</option>
|
||||
<option value={24}>Last 24 hours</option>
|
||||
<option value={168}>Last 7 days</option>
|
||||
<option value={720}>Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="form-control">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import toast from 'react-hot-toast'
|
||||
import type { DeviceRegistrationResponse } from '../types/api'
|
||||
|
||||
@@ -27,9 +29,63 @@ const copyToClipboard = (content: string, label: string) => {
|
||||
export default function CredentialsViewer({ credentials, deviceId }: CredentialsViewerProps) {
|
||||
const resolvedDeviceId = credentials.device_id || deviceId || 'device'
|
||||
const expiresAt = credentials.expires_at ? new Date(credentials.expires_at).toLocaleString() : null
|
||||
const [showQR, setShowQR] = useState(false)
|
||||
|
||||
// Read configuration from environment variables
|
||||
const deviceManagerUrl = import.meta.env.VITE_DEVICE_MANAGER_URL || 'http://localhost:8000'
|
||||
const mqttBroker = import.meta.env.VITE_MQTT_BROKER || 'localhost'
|
||||
const mqttPort = import.meta.env.VITE_MQTT_PORT || '8883'
|
||||
|
||||
const qrData = credentials.onboarding_token ? JSON.stringify({
|
||||
type: 'iot_device_onboarding',
|
||||
device_id: resolvedDeviceId,
|
||||
token: credentials.onboarding_token,
|
||||
api_url: deviceManagerUrl,
|
||||
broker: mqttBroker,
|
||||
port: parseInt(mqttPort, 10),
|
||||
}) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Secure QR Code for Mobile Onboarding */}
|
||||
{qrData && (
|
||||
<div className="rounded-lg bg-success/10 border border-success/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-success shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">Secure Mobile Onboarding</h3>
|
||||
<p className="text-sm opacity-80 mb-2">
|
||||
Scan this QR code with your mobile app to securely fetch certificates. Token expires in <strong>15 minutes</strong> and can only be used <strong>once</strong>.
|
||||
</p>
|
||||
<div className="alert alert-warning alert-sm mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-xs">This QR code will not be shown again. Scan it now!</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => setShowQR(!showQR)}
|
||||
>
|
||||
{showQR ? 'Hide QR Code' : 'Show QR Code'}
|
||||
</button>
|
||||
{showQR && (
|
||||
<div className="mt-4 flex justify-center p-6 bg-white rounded-lg border-2 border-success">
|
||||
<QRCodeSVG
|
||||
value={qrData}
|
||||
size={280}
|
||||
level="H"
|
||||
includeMargin={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(credentials.certificate_id || expiresAt) && (
|
||||
<div className="rounded-lg bg-base-200 p-4 text-sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
138
frontend/src/components/widgets/AirQualityWidget.tsx
Normal file
138
frontend/src/components/widgets/AirQualityWidget.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { weatherApi } from '../../api'
|
||||
|
||||
interface AirQualityWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
||||
// Get city from config or use default (Pulse.eco city)
|
||||
const city = (config.visualization as Record<string, unknown>)?.city as string || 'skopje'
|
||||
|
||||
const { data: airQuality, isLoading, error } = useQuery({
|
||||
queryKey: ['air-quality', city],
|
||||
queryFn: async () => {
|
||||
const response = await weatherApi.getAirQuality(city)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 300000, // Refresh every 5 minutes
|
||||
staleTime: 240000, // Consider fresh for 4 minutes
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card bg-base-100 shadow-lg h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card bg-base-100 shadow-lg h-full">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-sm">{config.title}</h2>
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<p className="text-error text-sm text-center">
|
||||
Failed to load air quality data for {city}
|
||||
</p>
|
||||
<p className="text-xs text-base-content/60 mt-2">
|
||||
Try: skopje, bitola, tetovo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!airQuality) return null
|
||||
|
||||
// Get AQI color based on status
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'good':
|
||||
return 'success'
|
||||
case 'moderate':
|
||||
return 'warning'
|
||||
case 'unhealthy for sensitive groups':
|
||||
case 'unhealthy':
|
||||
return 'error'
|
||||
case 'very unhealthy':
|
||||
case 'hazardous':
|
||||
return 'error'
|
||||
default:
|
||||
return 'base-content/40'
|
||||
}
|
||||
}
|
||||
|
||||
const statusColor = getStatusColor(airQuality.status)
|
||||
const pm10 = airQuality.measurements.pm10
|
||||
const pm25 = airQuality.measurements.pm25
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow-lg h-full">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-sm">{config.title}</h2>
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
{/* Air quality icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`h-16 w-16 text-${statusColor} mb-2`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* PM Values */}
|
||||
<div className="grid grid-cols-2 gap-4 w-full mb-3">
|
||||
{pm10 && (
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{pm10.average.toFixed(1)}</div>
|
||||
<div className="text-xs text-base-content/60">PM10 μg/m³</div>
|
||||
</div>
|
||||
)}
|
||||
{pm25 && (
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{pm25.average.toFixed(1)}</div>
|
||||
<div className="text-xs text-base-content/60">PM2.5 μg/m³</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AQI Status badge */}
|
||||
<div className={`badge badge-${statusColor} badge-lg`}>
|
||||
{airQuality.status}
|
||||
</div>
|
||||
|
||||
{/* Additional pollutants */}
|
||||
<div className="grid grid-cols-2 gap-2 mt-3 w-full text-xs">
|
||||
{Object.entries(airQuality.measurements).map(([pollutant, data]) => {
|
||||
if (pollutant === 'pm10' || pollutant === 'pm25') return null
|
||||
return (
|
||||
<div key={pollutant} className="flex justify-between">
|
||||
<span className="opacity-60">{pollutant.toUpperCase()}:</span>
|
||||
<span className="font-semibold">{data.average.toFixed(1)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* City and sensor count */}
|
||||
<div className="text-xs text-base-content/40 mt-3">
|
||||
{airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} • {airQuality.sensor_count} sensors
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
frontend/src/components/widgets/WeatherWidget.tsx
Normal file
110
frontend/src/components/widgets/WeatherWidget.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { weatherApi } from '../../api'
|
||||
|
||||
interface WeatherWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
// Get city from config or use default
|
||||
const city = (config.visualization as Record<string, unknown>)?.city as string || 'Skopje'
|
||||
|
||||
const { data: weather, isLoading, error } = useQuery({
|
||||
queryKey: ['weather', city],
|
||||
queryFn: async () => {
|
||||
const response = await weatherApi.getCurrent({ city })
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 300000, // Refresh every 5 minutes
|
||||
staleTime: 240000, // Consider fresh for 4 minutes
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card bg-base-100 shadow-lg h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card bg-base-100 shadow-lg h-full">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-sm">{config.title}</h2>
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<p className="text-error">Failed to load weather data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!weather) return null
|
||||
|
||||
// Weather code to icon mapping
|
||||
const getWeatherIcon = (code: number) => {
|
||||
if (code === 0 || code === 1) return '☀️' // Clear/Mainly clear
|
||||
if (code === 2) return '⛅' // Partly cloudy
|
||||
if (code === 3) return '☁️' // Overcast
|
||||
if (code >= 45 && code <= 48) return '🌫️' // Fog
|
||||
if (code >= 51 && code <= 55) return '🌦️' // Drizzle
|
||||
if (code >= 61 && code <= 65) return '🌧️' // Rain
|
||||
if (code >= 71 && code <= 77) return '🌨️' // Snow
|
||||
if (code >= 80 && code <= 82) return '🌧️' // Rain showers
|
||||
if (code >= 85 && code <= 86) return '🌨️' // Snow showers
|
||||
if (code >= 95) return '⛈️' // Thunderstorm
|
||||
return '🌡️'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow-lg h-full">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-sm">{config.title}</h2>
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
{/* Weather Icon */}
|
||||
<div className="text-6xl mb-2">{getWeatherIcon(weather.weather_code)}</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div className="text-4xl font-bold">{weather.temperature.toFixed(1)}°C</div>
|
||||
<div className="text-sm text-base-content/60">
|
||||
Feels like {weather.apparent_temperature.toFixed(1)}°C
|
||||
</div>
|
||||
|
||||
{/* Weather Description */}
|
||||
<div className="badge badge-primary badge-lg mt-2">
|
||||
{weather.weather_description}
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-4 w-full text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">💧</span>
|
||||
<span>{weather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">💨</span>
|
||||
<span>{weather.wind_speed.toFixed(1)} km/h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">☁️</span>
|
||||
<span>{weather.cloud_cover}%</span>
|
||||
</div>
|
||||
{weather.precipitation > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">🌧️</span>
|
||||
<span>{weather.precipitation} mm</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="text-xs text-base-content/40 mt-3">{weather.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import LineChartWidget from './LineChartWidget'
|
||||
import StatWidget from './StatWidget'
|
||||
import GaugeWidget from './GaugeWidget'
|
||||
import AiInsightWidget from './AiInsightWidget'
|
||||
import AirQualityWidget from './AirQualityWidget'
|
||||
import WeatherWidget from './WeatherWidget'
|
||||
|
||||
interface WidgetProps {
|
||||
config: WidgetConfig
|
||||
@@ -16,4 +18,6 @@ export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
|
||||
'gauge': GaugeWidget,
|
||||
'ai-insight': AiInsightWidget,
|
||||
'bar-chart': LineChartWidget, // Placeholder - implement later
|
||||
'air-quality': AirQualityWidget,
|
||||
'weather': WeatherWidget,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart'
|
||||
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather'
|
||||
|
||||
export interface WidgetConfig {
|
||||
id: string
|
||||
@@ -18,6 +18,7 @@ export interface WidgetConfig {
|
||||
showLegend?: boolean
|
||||
showGrid?: boolean
|
||||
height?: number
|
||||
city?: string
|
||||
}
|
||||
position?: {
|
||||
x: number
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface DeviceRegistrationResponse {
|
||||
certificate_pem?: string;
|
||||
private_key_pem?: string;
|
||||
expires_at?: string;
|
||||
onboarding_token?: string; // One-time token for secure onboarding (valid 15 min)
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
|
||||
Reference in New Issue
Block a user