Started new initial React frontend, aadditinal changes for Django to run REST.

This commit is contained in:
2025-11-04 00:25:03 +01:00
parent 391c08a738
commit 8e98f5ad7d
40 changed files with 8996 additions and 9 deletions

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5815
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
frontend/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"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",
"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-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"
},
"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
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.5 KiB

89
frontend/src/App.css Normal file
View File

@@ -0,0 +1,89 @@
/* Custom styles for the IoT Dashboard */
#root {
width: 100%;
min-height: 100vh;
}
/* Custom scrollbar for the drawer */
.drawer-side::-webkit-scrollbar {
width: 8px;
}
.drawer-side::-webkit-scrollbar-track {
background: transparent;
}
.drawer-side::-webkit-scrollbar-thumb {
background: hsl(var(--bc) / 0.2);
border-radius: 4px;
}
.drawer-side::-webkit-scrollbar-thumb:hover {
background: hsl(var(--bc) / 0.3);
}
/* Smooth transitions for interactive elements */
.btn,
.card {
transition: all 0.2s ease-in-out;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
/* Badge animations */
.badge {
transition: all 0.2s ease-in-out;
}
/* Stats animation on load */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stats {
animation: fadeInUp 0.5s ease-out;
}
/* Responsive table scrolling */
.overflow-x-auto {
scrollbar-width: thin;
scrollbar-color: hsl(var(--bc) / 0.2) transparent;
}
.overflow-x-auto::-webkit-scrollbar {
height: 8px;
}
.overflow-x-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-x-auto::-webkit-scrollbar-thumb {
background: hsl(var(--bc) / 0.2);
border-radius: 4px;
}
/* Loading spinner custom styles */
.loading {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

97
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,97 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import Dashboard from './pages/Dashboard'
import DeviceList from './pages/DeviceList'
import DeviceDetail from './pages/DeviceDetail'
import AddDevice from './pages/AddDevice'
import './App.css'
const queryClient = new QueryClient()
function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="drawer lg:drawer-open">
<input id="main-drawer" type="checkbox" className="drawer-toggle" />
<div className="drawer-content flex flex-col">
{/* Navbar */}
<div className="navbar bg-base-300 lg:hidden">
<div className="flex-none">
<label htmlFor="main-drawer" className="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-5 h-5 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
</div>
<div className="flex-1">
<span className="text-xl font-bold">IoT Dashboard</span>
</div>
</div>
{/* Page content */}
<main className="flex-1 bg-base-200">
{children}
</main>
</div>
{/* Sidebar */}
<div className="drawer-side">
<label htmlFor="main-drawer" className="drawer-overlay"></label>
<aside className="bg-base-100 w-64 min-h-full">
<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">
<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>IoT Dashboard</span>
</Link>
</div>
<ul className="menu p-4 space-y-2">
<li>
<NavLink
to="/"
className={({ isActive }) => isActive ? 'active' : ''}
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
</NavLink>
</li>
<li>
<NavLink
to="/devices"
className={({ isActive }) => isActive ? 'active' : ''}
>
<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
</NavLink>
</li>
</ul>
</aside>
</div>
</div>
)
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Toaster position="top-right" />
<Routes>
<Route path="/" element={<AppLayout><Dashboard /></AppLayout>} />
<Route path="/devices" element={<AppLayout><DeviceList /></AppLayout>} />
<Route path="/devices/add" element={<AppLayout><AddDevice /></AppLayout>} />
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
)
}
export default App

402
frontend/src/App.tsx.bak Normal file
View File

@@ -0,0 +1,402 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import Dashboard from './pages/Dashboard'
import DeviceList from './pages/DeviceList'
import DeviceDetail from './pages/DeviceDetail'
import AddDevice from './pages/AddDevice'
import './App.css'
const queryClient = new QueryClient()
function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="drawer lg:drawer-open">
<input id="main-drawer" type="checkbox" className="drawer-toggle" />
<div className="drawer-content flex flex-col">
{/* Navbar */}
<div className="navbar bg-base-300 lg:hidden">
<div className="flex-none">
<label htmlFor="main-drawer" className="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-5 h-5 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
</div>
<div className="flex-1">
<span className="text-xl font-bold">IoT Dashboard</span>
</div>
</div>
{/* Page content */}
<main className="flex-1 bg-base-200">
{children}
</main>
</div>
{/* Sidebar */}
<div className="drawer-side">
<label htmlFor="main-drawer" className="drawer-overlay"></label>
<aside className="bg-base-100 w-64 min-h-full">
<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">
<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>IoT Dashboard</span>
</Link>
</div>
<ul className="menu p-4 space-y-2">
<li>
<NavLink
to="/"
className={({ isActive }) => isActive ? 'active' : ''}
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
</NavLink>
</li>
<li>
<NavLink
to="/devices"
className={({ isActive }) => isActive ? 'active' : ''}
>
<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
</NavLink>
</li>
</ul>
</aside>
</div>
</div>
)
}
function App() {
queryKey: ['dashboard'],
queryFn: async () => {
const response = await dashboardApi.getOverview()
return response.data
},
})
const { data: devices, isLoading: devicesLoading } = useQuery({
queryKey: ['devices'],
queryFn: async () => {
const response = await devicesApi.getAll()
return response.data
},
})
if (overviewLoading || devicesLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<span className="loading loading-spinner loading-lg text-primary"></span>
</div>
)
}
return (
<div className="drawer lg:drawer-open">
<input id="drawer" type="checkbox" className="drawer-toggle" />
<div className="drawer-content flex flex-col">
{/* Navbar */}
<div className="navbar bg-base-100 shadow-lg">
<div className="flex-none lg:hidden">
<label htmlFor="drawer" className="btn btn-square btn-ghost">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-6 h-6 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</label>
</div>
<div className="flex-1">
<h1 className="text-2xl font-bold ml-2">IoT Dashboard</h1>
</div>
<div className="flex-none gap-2">
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle avatar">
<div className="w-10 rounded-full bg-primary text-primary-content flex items-center justify-center">
<span className="text-xl">U</span>
</div>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="p-4 md:p-8">
{/* Breadcrumbs */}
<div className="text-sm breadcrumbs mb-4">
<ul>
<li><a>Home</a></li>
<li>Dashboard</li>
</ul>
</div>
{/* Page Header */}
<div className="mb-6">
<h2 className="text-3xl font-bold">Dashboard Overview</h2>
<p className="text-base-content/70 mt-1">
Office Environment Intelligence Platform
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div className="stats shadow">
<div className="stat">
<div className="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div className="stat-title">Total Devices</div>
<div className="stat-value text-primary">{overview?.total_devices || 0}</div>
<div className="stat-desc">Registered in system</div>
</div>
</div>
<div className="stats shadow">
<div className="stat">
<div className="stat-figure text-success">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="stat-title">Active Devices</div>
<div className="stat-value text-success">{overview?.active_devices || 0}</div>
<div className="stat-desc">Currently online</div>
</div>
</div>
<div className="stats shadow">
<div className="stat">
<div className="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div className="stat-title">MQTT Devices</div>
<div className="stat-value text-secondary">{overview?.mqtt_devices || 0}</div>
<div className="stat-desc">Using mTLS</div>
</div>
</div>
<div className="stats shadow">
<div className="stat">
<div className="stat-figure text-warning">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
<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>
</div>
<div className="stat-title">Expiring Soon</div>
<div className="stat-value text-warning">{overview?.certificates_expiring_soon || 0}</div>
<div className="stat-desc">Certificates need renewal</div>
</div>
</div>
</div>
{/* Devices Section */}
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<div className="flex justify-between items-center mb-4">
<h2 className="card-title text-2xl">Devices</h2>
<button className="btn btn-primary btn-sm">
<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="M12 4v16m8-8H4" />
</svg>
Add Device
</button>
</div>
{devices && devices.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{devices.map((device: Device) => (
<div key={device.id} className="card bg-base-200 shadow-md hover:shadow-xl transition-shadow">
<div className="card-body">
<div className="flex justify-between items-start">
<h3 className="card-title text-lg">{device.name}</h3>
<div className={`badge ${device.is_active ? 'badge-success' : 'badge-ghost'}`}>
{device.is_active ? 'Active' : 'Inactive'}
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="font-semibold">ID:</span>
<code className="bg-base-300 px-2 py-1 rounded">{device.id}</code>
</div>
<div className="flex items-center gap-2">
<span className="font-semibold">Protocol:</span>
<div className="badge badge-outline">{device.protocol.toUpperCase()}</div>
</div>
{device.location && (
<div className="flex items-center gap-2">
<span className="font-semibold">Location:</span>
<span>{device.location}</span>
</div>
)}
{device.certificate_status && (
<div className="flex items-center gap-2">
<span className="font-semibold">Certificate:</span>
<div className={`badge ${
device.certificate_status === 'Valid' ? 'badge-success' :
device.certificate_status === 'Expiring Soon' ? 'badge-warning' :
'badge-error'
}`}>
{device.certificate_status}
</div>
</div>
)}
</div>
<div className="card-actions justify-end mt-4">
<button className="btn btn-sm btn-ghost">View</button>
<button className="btn btn-sm btn-primary">Manage</button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="alert">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-info shrink-0 w-6 h-6">
<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"></path>
</svg>
<span>No devices registered yet. Add your first device to get started!</span>
</div>
)}
</div>
</div>
{/* Recent Telemetry */}
{overview && overview.recent_telemetry.length > 0 && (
<div className="card bg-base-100 shadow-xl mt-8">
<div className="card-body">
<h2 className="card-title text-2xl mb-4">Recent Telemetry</h2>
<div className="overflow-x-auto">
<table className="table table-zebra">
<thead>
<tr>
<th>Device</th>
<th>Metric</th>
<th>Value</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{overview.recent_telemetry.map((t: { device_name: string; device_id: string; metric: string; value: number; unit?: string; time: string }, idx: number) => (
<tr key={idx} className="hover">
<td>
<div className="font-bold">{t.device_name}</div>
<div className="text-sm opacity-50">{t.device_id}</div>
</td>
<td>
<div className="badge badge-ghost">{t.metric}</div>
</td>
<td className="font-mono font-semibold">
{t.value} {t.unit || ''}
</td>
<td className="text-sm opacity-70">
{new Date(t.time).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</div>
{/* Drawer Sidebar */}
<div className="drawer-side z-10">
<label htmlFor="drawer" className="drawer-overlay"></label>
<aside className="bg-base-200 w-64 min-h-screen">
<div className="p-4">
<h2 className="text-xl font-bold mb-4">IoT Dashboard</h2>
</div>
<ul className="menu p-4 text-base-content">
<li>
<a className="active">
<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
</a>
</li>
<li>
<a>
<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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Devices
</a>
</li>
<li>
<a>
<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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Analytics
</a>
</li>
<li>
<a>
<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="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
AI Assistant
</a>
</li>
<li className="menu-title">
<span>Management</span>
</li>
<li>
<a>
<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 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>
Reports
</a>
</li>
<li>
<a>
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
</li>
</ul>
</aside>
</div>
</div>
)
}
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Toaster position="top-right" />
<Routes>
<Route path="/" element={<AppLayout><Dashboard /></AppLayout>} />
<Route path="/devices" element={<AppLayout><DeviceList /></AppLayout>} />
<Route path="/devices/add" element={<AppLayout><AddDevice /></AppLayout>} />
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
)
}
export default App

67
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,67 @@
import apiClient from '../lib/api-client';
import type {
Device,
DeviceRegistrationRequest,
DeviceRegistrationResponse,
Telemetry,
DashboardOverview,
} from '../types/api';
// Paginated response type from Django REST Framework
interface PaginatedResponse<T> {
count: number;
next: string | null;
previous: string | null;
results: T[];
}
// Device API
export const devicesApi = {
getAll: () => apiClient.get<PaginatedResponse<Device>>('/devices/'),
getOne: (id: string) => apiClient.get<Device>(`/devices/${id}/`),
create: (data: DeviceRegistrationRequest) =>
apiClient.post<DeviceRegistrationResponse>('/devices/', data),
delete: (id: string) => apiClient.delete(`/devices/${id}/`),
revoke: (id: string) => apiClient.post(`/devices/${id}/revoke/`),
renew: (id: string) =>
apiClient.post<DeviceRegistrationResponse>(`/devices/${id}/renew/`),
getTelemetry: (id: string, params?: {
metric?: string;
hours?: number;
limit?: number;
}) => apiClient.get<Telemetry[]>(`/devices/${id}/telemetry/`, { params }),
getMetrics: (id: string) =>
apiClient.get<{ device_id: string; device_name: string; metrics: string[] }>(
`/devices/${id}/metrics/`
),
};
// Telemetry API
export const telemetryApi = {
query: (params?: {
device_id?: string;
metric?: string;
hours?: number;
start_time?: string;
end_time?: string;
page_size?: number;
page?: number;
}) => apiClient.get<PaginatedResponse<Telemetry>>('/telemetry/', { params }),
getLatest: (params?: { limit?: number }) =>
apiClient.get<PaginatedResponse<Telemetry>>('/telemetry/latest/', { params }),
getMetrics: () => apiClient.get<{ metrics: string[] }>('/telemetry/metrics/'),
};
// Dashboard API
export const dashboardApi = {
getOverview: () => apiClient.get<DashboardOverview>('/dashboard/overview/'),
};

View File

@@ -0,0 +1 @@
<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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,136 @@
import toast from 'react-hot-toast'
import type { DeviceRegistrationResponse } from '../types/api'
interface CredentialsViewerProps {
credentials: DeviceRegistrationResponse
deviceId?: string
}
const downloadFile = (content: string, filename: string) => {
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
toast.success(`${filename} downloaded`)
}
const copyToClipboard = (content: string, label: string) => {
navigator.clipboard.writeText(content)
toast.success(`${label} copied to clipboard`)
}
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
return (
<div className="space-y-4">
{(credentials.certificate_id || expiresAt) && (
<div className="rounded-lg bg-base-200 p-4 text-sm">
<div className="flex flex-col gap-2">
{credentials.certificate_id && (
<div className="flex items-center justify-between">
<span className="font-semibold">Certificate ID</span>
<code className="bg-base-100 px-2 py-1 rounded">
{credentials.certificate_id}
</code>
</div>
)}
{expiresAt && (
<div className="flex items-center justify-between">
<span className="font-semibold">Expires At</span>
<span>{expiresAt}</span>
</div>
)}
</div>
</div>
)}
{credentials.ca_certificate_pem && (
<div>
<label className="label">
<span className="label-text font-semibold">CA Certificate</span>
</label>
<textarea
className="textarea textarea-bordered w-full font-mono text-xs h-32"
value={credentials.ca_certificate_pem}
readOnly
/>
<div className="flex gap-2 mt-2">
<button
className="btn btn-sm btn-outline"
onClick={() => copyToClipboard(credentials.ca_certificate_pem!, 'CA certificate')}
>
Copy
</button>
<button
className="btn btn-sm btn-outline"
onClick={() => downloadFile(credentials.ca_certificate_pem!, 'ca.crt')}
>
Download
</button>
</div>
</div>
)}
{credentials.certificate_pem && (
<div>
<label className="label">
<span className="label-text font-semibold">Device Certificate</span>
</label>
<textarea
className="textarea textarea-bordered w-full font-mono text-xs h-32"
value={credentials.certificate_pem}
readOnly
/>
<div className="flex gap-2 mt-2">
<button
className="btn btn-sm btn-outline"
onClick={() => copyToClipboard(credentials.certificate_pem!, 'Device certificate')}
>
Copy
</button>
<button
className="btn btn-sm btn-outline"
onClick={() => downloadFile(credentials.certificate_pem!, `${resolvedDeviceId}.crt`)}
>
Download
</button>
</div>
</div>
)}
{credentials.private_key_pem && (
<div>
<label className="label">
<span className="label-text font-semibold">Private Key</span>
</label>
<textarea
className="textarea textarea-bordered w-full font-mono text-xs h-32"
value={credentials.private_key_pem}
readOnly
/>
<div className="flex gap-2 mt-2">
<button
className="btn btn-sm btn-outline"
onClick={() => copyToClipboard(credentials.private_key_pem!, 'Private key')}
>
Copy
</button>
<button
className="btn btn-sm btn-outline"
onClick={() => downloadFile(credentials.private_key_pem!, `${resolvedDeviceId}.key`)}
>
Download
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,76 @@
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { devicesApi } from '../api'
import toast from 'react-hot-toast'
import type { AxiosError } from 'axios'
import type { Device } from '../types/api'
interface DeleteDeviceDialogProps {
device: Device
open: boolean
onOpenChange: (open: boolean) => void
onDeleted?: () => void
}
export default function DeleteDeviceDialog({ device, open, onOpenChange, onDeleted }: DeleteDeviceDialogProps) {
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: () => devicesApi.delete(device.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['devices'] })
queryClient.invalidateQueries({ queryKey: ['device', device.id] })
toast.success(`Device "${device.name}" deleted successfully`)
onDeleted?.()
onOpenChange(false)
},
onError: (error) => {
const axiosError = error as AxiosError<{ detail?: string }>
const message = axiosError.response?.data?.detail || axiosError.message
toast.error(`Failed to delete device: ${message}`)
},
})
return (
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<AlertDialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[500px] translate-x-[-50%] translate-y-[-50%] rounded-lg bg-base-100 p-6 shadow-xl">
<AlertDialog.Title className="text-2xl font-bold mb-2">
Delete Device
</AlertDialog.Title>
<AlertDialog.Description className="text-base-content/70 mb-6">
Are you sure you want to delete <strong>{device.name}</strong>? This action cannot be undone.
All associated telemetry data and certificates will be permanently removed.
</AlertDialog.Description>
<div className="flex justify-end gap-3">
<AlertDialog.Cancel asChild>
<button className="btn btn-ghost" disabled={deleteMutation.isPending}>
Cancel
</button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<button
className="btn btn-error"
onClick={(e) => {
e.preventDefault()
deleteMutation.mutate()
}}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<>
<span className="loading loading-spinner loading-sm"></span>
Deleting...
</>
) : (
'Delete Device'
)}
</button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
}

View File

@@ -0,0 +1,40 @@
import * as Dialog from '@radix-ui/react-dialog'
import CredentialsViewer from './CredentialsViewer'
import type { DeviceRegistrationResponse } from '../types/api'
interface DeviceCredentialsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
credentials: DeviceRegistrationResponse | null
deviceName?: string
}
export default function DeviceCredentialsDialog({ open, onOpenChange, credentials, deviceName }: DeviceCredentialsDialogProps) {
if (!credentials) {
return null
}
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 max-h-[85vh] w-[90vw] max-w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-lg bg-base-100 p-6 shadow-xl overflow-y-auto">
<Dialog.Title className="text-2xl font-bold mb-4">
{deviceName ? `${deviceName} Credentials` : 'Device Credentials'}
</Dialog.Title>
<Dialog.Description className="text-base-content/70 mb-4">
Store these credentials securely. They are only shown once after issuing the certificate.
</Dialog.Description>
<CredentialsViewer credentials={credentials} deviceId={credentials.device_id} />
<div className="flex justify-end mt-6">
<Dialog.Close asChild>
<button className="btn btn-primary">Done</button>
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}

View File

@@ -0,0 +1,101 @@
import * as Dialog from '@radix-ui/react-dialog'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { devicesApi } from '../api'
import toast from 'react-hot-toast'
import { useState } from 'react'
import CredentialsViewer from './CredentialsViewer'
import type { AxiosError } from 'axios'
import type { Device, DeviceRegistrationResponse } from '../types/api'
interface RenewDialogProps {
device: Device
open: boolean
onOpenChange: (open: boolean) => void
}
export default function RenewDialog({ device, open, onOpenChange }: RenewDialogProps) {
const queryClient = useQueryClient()
const [credentials, setCredentials] = useState<DeviceRegistrationResponse | null>(null)
const renewMutation = useMutation({
mutationFn: () => devicesApi.renew(device.id),
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['devices'] })
queryClient.invalidateQueries({ queryKey: ['device', device.id] })
setCredentials(response.data)
toast.success(`Certificate for "${device.name}" renewed successfully`)
},
onError: (error) => {
const axiosError = error as AxiosError<{ detail?: string }>
const message = axiosError.response?.data?.detail || axiosError.message
toast.error(`Failed to renew certificate: ${message}`)
},
})
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setCredentials(null)
onOpenChange(false)
}
}
return (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[600px] translate-x-[-50%] translate-y-[-50%] rounded-lg bg-base-100 p-6 shadow-xl overflow-y-auto">
<Dialog.Title className="text-2xl font-bold mb-4">
{credentials ? 'Certificate Renewed' : 'Renew Certificate'}
</Dialog.Title>
{!credentials ? (
<>
<Dialog.Description className="text-base-content/70 mb-6">
This will generate a new certificate for <strong>{device.name}</strong>.
You will need to update the device with the new credentials.
</Dialog.Description>
<div className="flex justify-end gap-3">
<Dialog.Close asChild>
<button className="btn btn-ghost" disabled={renewMutation.isPending}>
Cancel
</button>
</Dialog.Close>
<button
className="btn btn-warning"
onClick={() => renewMutation.mutate()}
disabled={renewMutation.isPending}
>
{renewMutation.isPending ? (
<>
<span className="loading loading-spinner loading-sm"></span>
Renewing...
</>
) : (
'Renew Certificate'
)}
</button>
</div>
</>
) : (
<>
<div className="alert alert-warning mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" 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>Save these credentials now! They will not be shown again.</span>
</div>
<CredentialsViewer credentials={credentials} deviceId={device.id} />
<div className="flex justify-end mt-6">
<Dialog.Close asChild>
<button className="btn btn-primary">Done</button>
</Dialog.Close>
</div>
</>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}

View File

@@ -0,0 +1,74 @@
import * as AlertDialog from '@radix-ui/react-alert-dialog'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { devicesApi } from '../api'
import toast from 'react-hot-toast'
import type { AxiosError } from 'axios'
import type { Device } from '../types/api'
interface RevokeDialogProps {
device: Device
open: boolean
onOpenChange: (open: boolean) => void
}
export default function RevokeDialog({ device, open, onOpenChange }: RevokeDialogProps) {
const queryClient = useQueryClient()
const revokeMutation = useMutation({
mutationFn: () => devicesApi.revoke(device.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['devices'] })
queryClient.invalidateQueries({ queryKey: ['device', device.id] })
toast.success(`Certificate for "${device.name}" revoked successfully`)
onOpenChange(false)
},
onError: (error) => {
const axiosError = error as AxiosError<{ detail?: string }>
const message = axiosError.response?.data?.detail || axiosError.message
toast.error(`Failed to revoke certificate: ${message}`)
},
})
return (
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
<AlertDialog.Portal>
<AlertDialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<AlertDialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[500px] translate-x-[-50%] translate-y-[-50%] rounded-lg bg-base-100 p-6 shadow-xl">
<AlertDialog.Title className="text-2xl font-bold mb-2">
Revoke Certificate
</AlertDialog.Title>
<AlertDialog.Description className="text-base-content/70 mb-6">
Are you sure you want to revoke the certificate for <strong>{device.name}</strong>?
The device will no longer be able to connect until you renew its certificate.
</AlertDialog.Description>
<div className="flex justify-end gap-3">
<AlertDialog.Cancel asChild>
<button className="btn btn-ghost" disabled={revokeMutation.isPending}>
Cancel
</button>
</AlertDialog.Cancel>
<AlertDialog.Action asChild>
<button
className="btn btn-warning"
onClick={(e) => {
e.preventDefault()
revokeMutation.mutate()
}}
disabled={revokeMutation.isPending}
>
{revokeMutation.isPending ? (
<>
<span className="loading loading-spinner loading-sm"></span>
Revoking...
</>
) : (
'Revoke Certificate'
)}
</button>
</AlertDialog.Action>
</div>
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
)
}

View File

@@ -0,0 +1,84 @@
import { ResponsiveContainer, LineChart, Line, Tooltip, XAxis, YAxis, CartesianGrid } from 'recharts'
interface TelemetryTrendCardProps {
title: string
data: Array<{ time: string; value: number }>
unit?: string
accentColor?: string
subtitle?: string
}
function formatTimeLabel(timestamp: string) {
const date = new Date(timestamp)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function formatValue(value: number, unit?: string) {
const rounded = Number.isInteger(value) ? value : value.toFixed(1)
return unit ? `${rounded} ${unit}` : String(rounded)
}
export default function TelemetryTrendCard({ title, data, unit, accentColor = '#2563eb', subtitle }: TelemetryTrendCardProps) {
const latest = data.at(-1)
return (
<div className="card bg-base-100 shadow-xl">
<div className="card-body gap-4">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">{title}</h3>
{subtitle && <p className="text-sm text-base-content/60">{subtitle}</p>}
</div>
{latest ? (
<div className="text-right">
<div className="text-3xl font-bold text-primary">
{formatValue(latest.value, unit)}
</div>
<div className="text-xs text-base-content/60">as of {formatTimeLabel(latest.time)}</div>
</div>
) : (
<div className="text-sm text-base-content/60">No data</div>
)}
</div>
<div className="h-48">
{data.length > 1 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--bc) / 0.1)" />
<XAxis
dataKey="time"
tickFormatter={formatTimeLabel}
tick={{ fontSize: 12 }}
stroke="hsl(var(--bc) / 0.3)"
/>
<YAxis
tickFormatter={(val) => formatValue(val, unit)}
width={48}
tick={{ fontSize: 12 }}
stroke="hsl(var(--bc) / 0.3)"
/>
<Tooltip
formatter={(value: number) => formatValue(value, unit)}
labelFormatter={(label) => formatTimeLabel(String(label))}
/>
<Line
type="monotone"
dataKey="value"
stroke={accentColor}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-full items-center justify-center text-sm text-base-content/60">
Not enough telemetry to chart yet
</div>
)}
</div>
</div>
</div>
)
}

126
frontend/src/index.css Normal file
View File

@@ -0,0 +1,126 @@
@import "tailwindcss";
@plugin "daisyui";
/* DaisyUI theme configuration */
@theme {
--dui-themes: light, dark, cupcake, corporate;
}
/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* Dark mode scrollbar */
@media (prefers-color-scheme: dark) {
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
}
/* Smooth transitions */
.btn,
.card {
transition: all 0.2s ease-in-out;
}
.card:hover {
transform: translateY(-2px);
}
/* Animations */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stats {
animation: fadeInUp 0.5s ease-out;
}
/* Radix UI Dialog/AlertDialog overlays and content */
@keyframes overlayShow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes contentShow {
from {
opacity: 0;
transform: translate(-50%, -48%) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
/* Dialog/AlertDialog Overlay */
[data-radix-dialog-overlay],
[data-radix-alert-dialog-overlay] {
background-color: rgba(0, 0, 0, 0.5);
position: fixed;
inset: 0;
animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
z-index: 50;
}
/* Dialog/AlertDialog Content */
[data-radix-dialog-content],
[data-radix-alert-dialog-content] {
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-width: 450px;
max-height: 85vh;
padding: 1.5rem;
animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
z-index: 51;
}
[data-radix-dialog-content]:focus,
[data-radix-alert-dialog-content]:focus {
outline: none;
}
/* Dark mode support for dialogs */
@media (prefers-color-scheme: dark) {
[data-radix-dialog-content],
[data-radix-alert-dialog-content] {
background-color: #1f2937;
color: white;
}
}

View File

@@ -0,0 +1,36 @@
import axios from 'axios';
// Use Vite proxy in development, or env variable in production
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // For session auth
});
// Add request interceptor for JWT token (if using JWT)
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Add response interceptor for error handling
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized - redirect to login
localStorage.removeItem('access_token');
// window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;

29
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,29 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString()
}
export function formatRelativeTime(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
const now = new Date()
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (seconds < 60) return `${seconds}s ago`
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
if (days < 30) return `${days}d ago`
return formatDate(d)
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,160 @@
import { Link } from 'react-router-dom'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import type { AxiosError } from 'axios'
import { devicesApi } from '../api'
import DeviceCredentialsDialog from '../components/DeviceCredentialsDialog'
import type { DeviceRegistrationRequest, DeviceRegistrationResponse } from '../types/api'
type DeviceRegistrationForm = DeviceRegistrationRequest
export default function AddDevice() {
const queryClient = useQueryClient()
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<DeviceRegistrationForm>({
defaultValues: {
protocol: 'mqtt',
},
})
const [credentials, setCredentials] = useState<DeviceRegistrationResponse | null>(null)
const [credentialsOpen, setCredentialsOpen] = useState(false)
const registerMutation = useMutation({
mutationFn: (payload: DeviceRegistrationRequest) => devicesApi.create(payload),
onSuccess: (response) => {
setCredentials(response.data)
setCredentialsOpen(true)
toast.success('Device registered successfully')
queryClient.invalidateQueries({ queryKey: ['devices'] })
reset({ name: '', location: '', protocol: 'mqtt' })
},
onError: (error) => {
const axiosError = error as AxiosError<{ detail?: string }>
const message = axiosError.response?.data?.detail || axiosError.message
toast.error(`Failed to register device: ${message}`)
},
})
const onSubmit = (data: DeviceRegistrationForm) => {
if (data.protocol !== 'mqtt') {
toast.error('Only MQTT devices are supported right now')
return
}
registerMutation.mutate({
name: data.name.trim(),
location: data.location?.trim() || undefined,
protocol: 'mqtt',
})
}
return (
<div className="p-6">
<div className="mb-6">
<Link to="/devices" className="btn btn-ghost btn-sm mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Devices
</Link>
<h1 className="text-3xl font-bold">Add New Device</h1>
</div>
<div className="card bg-base-100 shadow-xl max-w-2xl">
<div className="card-body">
<h2 className="card-title">Device Registration</h2>
<p className="text-sm opacity-70 mb-4">
Register a new IoT device. For MQTT devices, a certificate will be automatically generated.
</p>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Device Name *</span>
</label>
<input
type="text"
placeholder="e.g., Office Temperature Sensor"
className={`input input-bordered w-full ${errors.name ? 'input-error' : ''}`}
{...register('name', { required: 'Device name is required' })}
/>
{errors.name && (
<label className="label">
<span className="label-text-alt text-error">{errors.name.message}</span>
</label>
)}
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Location</span>
</label>
<input
type="text"
placeholder="e.g., Office Room 101"
className="input input-bordered w-full"
{...register('location')}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Protocol *</span>
</label>
<select
className="select select-bordered w-full"
{...register('protocol')}
>
<option value="mqtt">MQTT (with mTLS)</option>
<option value="http" disabled>
HTTP (coming soon)
</option>
<option value="webhook" disabled>
Webhook (coming soon)
</option>
</select>
<label className="label">
<span className="label-text-alt">MQTT devices will receive a certificate for secure communication</span>
</label>
</div>
<div className="card-actions justify-end mt-6">
<Link to="/devices" className="btn btn-ghost">
Cancel
</Link>
<button type="submit" className="btn btn-primary" disabled={registerMutation.isPending}>
{registerMutation.isPending ? (
<>
<span className="loading loading-spinner loading-sm" />
Registering...
</>
) : (
'Register Device'
)}
</button>
</div>
</form>
</div>
</div>
<DeviceCredentialsDialog
open={credentialsOpen}
credentials={credentials}
deviceName={credentials?.device_id}
onOpenChange={(open) => {
setCredentialsOpen(open)
if (!open) {
setCredentials(null)
}
}}
/>
</div>
)
}

View File

@@ -0,0 +1,401 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { dashboardApi, telemetryApi } from '../api'
import TelemetryTrendCard from '../components/dashboard/TelemetryTrendCard'
import type { DashboardOverview, Telemetry } from '../types/api'
type TelemetryQueryResult = Telemetry[] | { results?: Telemetry[] }
type MetricSummary = {
metricKey: string
label: string
unit?: string
samples: Array<{ time: string; value: number }>
latest?: { time: string; value: number }
earliest?: { time: string; value: number }
average: number
change?: number
count: number
}
export default function Dashboard() {
const {
data: overview,
isLoading: overviewLoading,
isFetching: overviewFetching,
refetch: refetchOverview,
} = useQuery({
queryKey: ['dashboard', 'overview'],
queryFn: async (): Promise<DashboardOverview> => {
const response = await dashboardApi.getOverview()
return response.data
},
refetchInterval: 5000,
})
const {
data: telemetryFeed,
isLoading: telemetryLoading,
isFetching: telemetryFetching,
refetch: refetchTelemetry,
} = useQuery({
queryKey: ['telemetry', 'feed', { page_size: 200 }],
queryFn: async (): Promise<TelemetryQueryResult> => {
const response = await telemetryApi.query({ page_size: 200 })
return response.data
},
refetchInterval: 15000,
})
const telemetrySamples = useMemo<Telemetry[]>(() => {
if (!telemetryFeed) {
return []
}
if (Array.isArray(telemetryFeed)) {
return telemetryFeed
}
const maybeResults = telemetryFeed.results
if (Array.isArray(maybeResults)) {
return maybeResults
}
return []
}, [telemetryFeed])
const metricSummaries = useMemo<MetricSummary[]>(() => {
if (!telemetrySamples.length) {
return []
}
const groups = new Map<string, MetricSummary>()
telemetrySamples.forEach((sample) => {
const metricKey = sample.metric.toLowerCase()
if (!groups.has(metricKey)) {
const label = sample.metric
.replace(/_/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
groups.set(metricKey, {
metricKey,
label,
unit: sample.unit,
samples: [],
average: 0,
count: 0,
})
}
groups.get(metricKey)!.samples.push({ time: sample.time, value: sample.value })
})
return Array.from(groups.values())
.map((group) => {
const ordered = [...group.samples].sort(
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
)
const total = ordered.reduce((acc, cur) => acc + Number(cur.value), 0)
const average = total / ordered.length
const latest = ordered.at(-1)
const earliest = ordered[0]
const change = latest && earliest ? latest.value - earliest.value : undefined
return {
...group,
samples: ordered,
latest,
earliest,
average,
change,
count: ordered.length,
}
})
.sort((a, b) => b.count - a.count)
}, [telemetrySamples])
const primaryMetric = useMemo<MetricSummary | undefined>(() => {
if (!metricSummaries.length) {
return undefined
}
const prefersTrend = metricSummaries.find(
(metric) => metric.count > 1 && metric.metricKey.includes('temp'),
)
if (prefersTrend) {
return prefersTrend
}
const anyWithTrend = metricSummaries.find((metric) => metric.count > 1)
if (anyWithTrend) {
return anyWithTrend
}
return metricSummaries[0]
}, [metricSummaries])
const isLoading = overviewLoading && telemetryLoading
const formatValue = (value?: number, unit?: string) => {
if (value === undefined || Number.isNaN(value)) {
return '—'
}
const rounded = Number.isInteger(value) ? value : Number(value.toFixed(1))
return unit ? `${rounded} ${unit}` : `${rounded}`
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<span className="loading loading-spinner loading-lg"></span>
</div>
)
}
return (
<div className="p-6 space-y-10">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-3xl font-bold">Environment Overview</h1>
<p className="text-base-content/70">
Live snapshot of workplace telemetry and system health. Focus on environmental
trendsdevice controls are just a click away.
</p>
</div>
<button
className="btn btn-outline btn-sm w-full sm:w-auto"
onClick={() => {
refetchOverview()
refetchTelemetry()
}}
disabled={overviewFetching || telemetryFetching}
>
{overviewFetching || telemetryFetching ? (
<span className="loading loading-spinner loading-xs"></span>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9M20 20v-5h-.581m-15.357-2a8.003 8.003 0 0115.357 2"
/>
</svg>
)}
<span className="ml-2">Refresh</span>
</button>
</div>
{/* Environmental Snapshot */}
<section className="space-y-4">
<h2 className="text-xl font-semibold">Environmental Snapshot</h2>
{telemetryLoading && !metricSummaries.length ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((key) => (
<div key={key} className="card bg-base-200 animate-pulse">
<div className="card-body h-32"></div>
</div>
))}
</div>
) : metricSummaries.length ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{metricSummaries.slice(0, 3).map((metric) => (
<div key={metric.metricKey} className="card bg-base-100 shadow">
<div className="card-body">
<div className="text-sm uppercase tracking-wide text-base-content/60">
{metric.label}
</div>
<div className="text-4xl font-bold text-primary">
{formatValue(metric.latest?.value, metric.unit)}
</div>
<div className="flex items-center justify-between text-sm text-base-content/60">
<span>Avg (last {metric.count})</span>
<span>{formatValue(metric.average, metric.unit)}</span>
</div>
{metric.change !== undefined && metric.change !== 0 && (
<div
className={`text-sm font-medium ${
metric.change > 0 ? 'text-warning' : 'text-success'
}`}
>
{metric.change > 0 ? '+' : ''}
{formatValue(metric.change, metric.unit)} since first sample
</div>
)}
</div>
</div>
))}
</div>
) : (
<div className="card bg-base-200">
<div className="card-body text-sm text-base-content/70">
No telemetry ingested yet. Connect devices or publish MQTT data to see environmental metrics.
</div>
</div>
)}
</section>
{/* Featured Trend */}
{primaryMetric && (
<section className="space-y-4">
<h2 className="text-xl font-semibold">Featured Trend</h2>
<TelemetryTrendCard
title={primaryMetric.label}
data={primaryMetric.samples}
unit={primaryMetric.unit}
subtitle={`Latest ${primaryMetric.count} readings`}
/>
</section>
)}
{/* Stats Grid */}
<section className="space-y-4">
<h2 className="text-xl font-semibold">System Health</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="stats shadow">
<div className="stat">
<div className="stat-figure text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="inline-block w-8 h-8 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
</div>
<div className="stat-title">Total Devices</div>
<div className="stat-value text-primary">{overview?.total_devices ?? 0}</div>
<div className="stat-desc">Registered in system</div>
</div>
</div>
<div className="stats shadow">
<div className="stat">
<div className="stat-figure text-success">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="inline-block w-8 h-8 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div className="stat-title">Active Devices</div>
<div className="stat-value text-success">{overview?.active_devices ?? 0}</div>
<div className="stat-desc">Currently online</div>
</div>
</div>
<div className="stats shadow">
<div className="stat">
<div className="stat-figure text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="inline-block w-8 h-8 stroke-current"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-15.857 21.213 0"
/>
</svg>
</div>
<div className="stat-title">MQTT Devices</div>
<div className="stat-value text-secondary">{overview?.mqtt_devices ?? 0}</div>
<div className="stat-desc">Using mTLS</div>
</div>
</div>
<div className="stats shadow">
<div className="stat">
<div className="stat-figure text-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="inline-block w-8 h-8 stroke-current"
>
<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>
</div>
<div className="stat-title">Expiring Soon</div>
<div className="stat-value text-warning">
{overview?.certificates_expiring_soon ?? 0}
</div>
<div className="stat-desc">Certificates need renewal</div>
</div>
</div>
</div>
</section>
{/* Recent Telemetry */}
{overview?.recent_telemetry?.length ? (
<section className="space-y-4">
<h2 className="text-2xl font-bold">Recent Telemetry</h2>
<div className="overflow-x-auto">
<table className="table table-zebra">
<thead>
<tr>
<th>Device</th>
<th>Metric</th>
<th>Value</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{overview.recent_telemetry.map((t, idx) => (
<tr key={`${t.device_id}-${t.metric}-${idx}`} className="hover">
<td>
<div className="font-bold">{t.device_name}</div>
<div className="text-sm opacity-50">{t.device_id}</div>
</td>
<td>
<div className="badge badge-ghost">{t.metric}</div>
</td>
<td className="font-mono font-semibold">
{formatValue(t.value, t.unit)}
</td>
<td className="text-sm opacity-70">
{new Date(t.time).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
) : null}
</div>
)
}

View File

@@ -0,0 +1,187 @@
import { useParams, Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { devicesApi } from '../api'
import DeleteDeviceDialog from '../components/DeleteDeviceDialog'
import RenewDialog from '../components/RenewDialog'
import RevokeDialog from '../components/RevokeDialog'
export default function DeviceDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [deleteOpen, setDeleteOpen] = useState(false)
const [renewOpen, setRenewOpen] = useState(false)
const [revokeOpen, setRevokeOpen] = useState(false)
const { data: device, isLoading } = useQuery({
queryKey: ['device', id],
queryFn: async () => {
const response = await devicesApi.getOne(id!)
return response.data
},
enabled: !!id,
})
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<span className="loading loading-spinner loading-lg"></span>
</div>
)
}
if (!device) {
return (
<div className="p-6">
<div className="alert alert-error">
<span>Device not found</span>
</div>
<Link to="/devices" className="btn btn-ghost mt-4">
Back to Device List
</Link>
</div>
)
}
return (
<div className="p-6">
<div className="mb-6">
<Link to="/devices" className="btn btn-ghost btn-sm mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Devices
</Link>
<h1 className="text-3xl font-bold">Device Details</h1>
</div>
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<h2 className="card-title text-2xl mb-4">{device.name}</h2>
<div className="overflow-x-auto">
<table className="table">
<tbody>
<tr>
<th className="w-1/3">Device ID:</th>
<td><code className="bg-base-200 px-3 py-1 rounded">{device.id}</code></td>
</tr>
<tr>
<th>Location:</th>
<td>{device.location || '—'}</td>
</tr>
<tr>
<th>Protocol:</th>
<td>
<div className="badge badge-info">{device.protocol.toUpperCase()}</div>
</td>
</tr>
<tr>
<th>Status:</th>
<td>
<div className={`badge ${device.is_active ? 'badge-success' : 'badge-ghost'}`}>
{device.is_active ? 'Active' : 'Inactive'}
</div>
</td>
</tr>
<tr>
<th>Created:</th>
<td>{new Date(device.created_at).toLocaleString()}</td>
</tr>
</tbody>
</table>
</div>
{/* Certificate Information for MQTT devices */}
{device.protocol === 'mqtt' && device.active_certificate && (
<div className="mt-6">
<h3 className="text-xl font-bold mb-4">Certificate Information</h3>
<div className="overflow-x-auto">
<table className="table">
<tbody>
<tr>
<th className="w-1/3">Certificate ID:</th>
<td><code className="bg-base-200 px-3 py-1 rounded">{device.active_certificate.id}</code></td>
</tr>
<tr>
<th>Issued At:</th>
<td>{new Date(device.active_certificate.issued_at).toLocaleString()}</td>
</tr>
<tr>
<th>Expires At:</th>
<td>{new Date(device.active_certificate.expires_at).toLocaleString()}</td>
</tr>
<tr>
<th>Days Until Expiry:</th>
<td>
<span className={`font-semibold ${
device.active_certificate.days_until_expiry < 30 ? 'text-warning' :
device.active_certificate.days_until_expiry < 7 ? 'text-error' :
'text-success'
}`}>
{device.active_certificate.days_until_expiry} days
</span>
</td>
</tr>
<tr>
<th>Status:</th>
<td>
{device.active_certificate.revoked_at ? (
<div className="badge badge-error">Revoked</div>
) : device.active_certificate.is_expired ? (
<div className="badge badge-error">Expired</div>
) : device.active_certificate.is_expiring_soon ? (
<div className="badge badge-warning">Expiring Soon</div>
) : (
<div className="badge badge-success">Active</div>
)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{/* Action Buttons */}
<div className="card-actions justify-end mt-6">
{device.protocol === 'mqtt' && (
<>
<button className="btn btn-outline btn-warning" onClick={() => setRenewOpen(true)}>
Renew Certificate
</button>
<button className="btn btn-outline btn-error" onClick={() => setRevokeOpen(true)}>
Revoke Certificate
</button>
</>
)}
<button className="btn btn-error" onClick={() => setDeleteOpen(true)}>
Delete Device
</button>
</div>
</div>
</div>
<DeleteDeviceDialog
device={device}
open={deleteOpen}
onOpenChange={(open) => setDeleteOpen(open)}
onDeleted={() => navigate('/devices')}
/>
{device.protocol === 'mqtt' && (
<>
<RenewDialog
device={device}
open={renewOpen}
onOpenChange={(open) => setRenewOpen(open)}
/>
<RevokeDialog
device={device}
open={revokeOpen}
onOpenChange={(open) => setRevokeOpen(open)}
/>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,157 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { devicesApi } from '../api'
import type { Device } from '../types/api'
import DeleteDeviceDialog from '../components/DeleteDeviceDialog'
import RevokeDialog from '../components/RevokeDialog'
import RenewDialog from '../components/RenewDialog'
export default function DeviceList() {
const [deleteDevice, setDeleteDevice] = useState<Device | null>(null)
const [revokeDevice, setRevokeDevice] = useState<Device | null>(null)
const [renewDevice, setRenewDevice] = useState<Device | null>(null)
const { data: devicesData, isLoading } = useQuery({
queryKey: ['devices'],
queryFn: async () => {
const response = await devicesApi.getAll()
return response.data
},
})
const devices = devicesData?.results || []
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<span className="loading loading-spinner loading-lg"></span>
</div>
)
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">Manage Devices</h1>
<Link to="/devices/add" className="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Device
</Link>
</div>
<div className="overflow-x-auto">
<table className="table table-zebra w-full">
<thead>
<tr>
<th>Name</th>
<th>Location</th>
<th>Protocol</th>
<th>Certificate Status</th>
<th>Certificate Expiry</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{devices && devices.length > 0 ? (
devices.map((device: Device) => {
const expiresAt = device.active_certificate?.expires_at ?? device.certificate_expires_at
return (
<tr key={device.id} className="hover">
<td className="font-semibold">{device.name}</td>
<td>{device.location || '—'}</td>
<td>
<div className="badge badge-info">{device.protocol.toUpperCase()}</div>
</td>
<td>
{device.protocol === 'mqtt' ? (
<div className={`badge ${
device.certificate_status === 'Valid' ? 'badge-success' :
device.certificate_status === 'Expiring Soon' ? 'badge-warning' :
'badge-error'
}`}>
{device.certificate_status || 'Unknown'}
</div>
) : (
<span className="badge badge-ghost">N/A</span>
)}
</td>
<td>{expiresAt ? new Date(expiresAt).toLocaleString() : '—'}</td>
<td>
<div className="flex gap-2">
<Link to={`/devices/${device.id}`} className="btn btn-outline btn-info btn-xs">
View
</Link>
<button
className="btn btn-error btn-xs"
onClick={() => setDeleteDevice(device)}
>
Delete
</button>
{device.protocol === 'mqtt' && (
<>
<button
className="btn btn-outline btn-warning btn-xs"
onClick={() => setRenewDevice(device)}
>
Renew
</button>
<button
className="btn btn-outline btn-error btn-xs"
onClick={() => setRevokeDevice(device)}
>
Revoke
</button>
</>
)}
</div>
</td>
</tr>
)
})
) : (
<tr>
<td colSpan={6} className="text-center py-8">
<div className="flex flex-col items-center gap-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 opacity-30" 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>
<p className="text-lg opacity-60">No devices found.</p>
<Link to="/devices/add" className="btn btn-primary btn-sm">
Add Your First Device
</Link>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Dialogs */}
{deleteDevice && (
<DeleteDeviceDialog
device={deleteDevice}
open={!!deleteDevice}
onOpenChange={(open) => !open && setDeleteDevice(null)}
/>
)}
{revokeDevice && (
<RevokeDialog
device={revokeDevice}
open={!!revokeDevice}
onOpenChange={(open) => !open && setRevokeDevice(null)}
/>
)}
{renewDevice && (
<RenewDialog
device={renewDevice}
open={!!renewDevice}
onOpenChange={(open) => !open && setRenewDevice(null)}
/>
)}
</div>
)
}

65
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,65 @@
export interface Device {
id: string;
name: string;
location?: string;
protocol: 'mqtt' | 'http' | 'webhook';
connection_config?: Record<string, any>;
is_active: boolean;
created_at: string;
certificate_status?: string;
certificate_expires_at?: string;
active_certificate?: DeviceCertificate;
}
export interface DeviceCertificate {
id: string;
device_id: string;
issued_at: string;
expires_at: string;
revoked_at?: string;
is_revoked: boolean;
is_expired: boolean;
is_expiring_soon: boolean;
is_valid: boolean;
days_until_expiry: number;
}
export interface Telemetry {
time: string;
device_id: string;
device_name: string;
metric: string;
value: number;
unit?: string;
}
export interface DeviceRegistrationRequest {
name: string;
location?: string;
protocol?: 'mqtt' | 'http' | 'webhook';
connection_config?: Record<string, any>;
}
export interface DeviceRegistrationResponse {
device_id: string;
protocol: string;
certificate_id?: string;
ca_certificate_pem?: string;
certificate_pem?: string;
private_key_pem?: string;
expires_at?: string;
}
export interface DashboardOverview {
total_devices: number;
active_devices: number;
mqtt_devices: number;
http_devices: number;
certificates_expiring_soon: number;
recent_telemetry: Telemetry[];
devices_with_metrics: {
device_id: string;
device_name: string;
metrics: string[];
}[];
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

23
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})

View File

@@ -0,0 +1 @@
"""REST API for IoT Dashboard."""

View File

@@ -0,0 +1,79 @@
"""DRF serializers for IoT Dashboard models."""
from rest_framework import serializers
from iotDashboard.models import Device, DeviceCertificate, Telemetry
class DeviceCertificateSerializer(serializers.ModelSerializer):
"""Serializer for device certificates."""
is_revoked = serializers.ReadOnlyField()
is_expired = serializers.ReadOnlyField()
is_expiring_soon = serializers.ReadOnlyField()
is_valid = serializers.ReadOnlyField()
days_until_expiry = serializers.ReadOnlyField()
class Meta:
model = DeviceCertificate
fields = [
'id', 'device_id', 'issued_at', 'expires_at',
'revoked_at', 'is_revoked', 'is_expired',
'is_expiring_soon', 'is_valid', 'days_until_expiry'
]
# Don't expose private keys in API
# certificate_pem and private_key_pem are excluded by default
class DeviceSerializer(serializers.ModelSerializer):
"""Serializer for devices with certificate status."""
certificate_status = serializers.ReadOnlyField()
active_certificate = DeviceCertificateSerializer(read_only=True)
class Meta:
model = Device
fields = [
'id', 'name', 'location', 'protocol',
'connection_config', 'is_active', 'created_at',
'certificate_status', 'active_certificate'
]
read_only_fields = ['id', 'created_at']
class DeviceCreateSerializer(serializers.Serializer):
"""Serializer for device registration requests."""
name = serializers.CharField(max_length=255)
location = serializers.CharField(max_length=255, required=False, allow_blank=True)
protocol = serializers.ChoiceField(choices=['mqtt', 'http', 'webhook'], default='mqtt')
connection_config = serializers.JSONField(required=False, allow_null=True)
class TelemetrySerializer(serializers.ModelSerializer):
"""Serializer for telemetry data."""
device_name = serializers.ReadOnlyField()
class Meta:
model = Telemetry
fields = ['time', 'device_id', 'device_name', 'metric', 'value', 'unit']
class DeviceMetricsSerializer(serializers.Serializer):
"""Serializer for device metrics list."""
device_id = serializers.CharField()
device_name = serializers.CharField()
metrics = serializers.ListField(child=serializers.CharField())
class DashboardOverviewSerializer(serializers.Serializer):
"""Serializer for dashboard overview data."""
total_devices = serializers.IntegerField()
active_devices = serializers.IntegerField()
mqtt_devices = serializers.IntegerField()
http_devices = serializers.IntegerField()
certificates_expiring_soon = serializers.IntegerField()
recent_telemetry = TelemetrySerializer(many=True)
devices_with_metrics = DeviceMetricsSerializer(many=True)

15
iotDashboard/api/urls.py Normal file
View File

@@ -0,0 +1,15 @@
"""URL routing for IoT Dashboard REST API."""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import DeviceViewSet, TelemetryViewSet, DashboardViewSet
# Create router and register viewsets
router = DefaultRouter()
router.register(r'devices', DeviceViewSet, basename='device')
router.register(r'telemetry', TelemetryViewSet, basename='telemetry')
router.register(r'dashboard', DashboardViewSet, basename='dashboard')
urlpatterns = [
path('', include(router.urls)),
]

303
iotDashboard/api/views.py Normal file
View File

@@ -0,0 +1,303 @@
"""DRF ViewSets for IoT Dashboard API."""
from datetime import timedelta
from django.utils import timezone
from django.db.models import Q, Count
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from iotDashboard.models import Device, DeviceCertificate, Telemetry
from iotDashboard.device_manager_client import (
DeviceManagerClient,
DeviceManagerAPIError
)
from .serializers import (
DeviceSerializer,
DeviceCreateSerializer,
DeviceCertificateSerializer,
TelemetrySerializer,
DashboardOverviewSerializer,
DeviceMetricsSerializer,
)
device_manager = DeviceManagerClient()
class DeviceViewSet(viewsets.ModelViewSet):
"""ViewSet for device management."""
queryset = Device.objects.all()
serializer_class = DeviceSerializer
# permission_classes = [IsAuthenticated] # Uncomment for production
def get_serializer_class(self):
if self.action == 'create':
return DeviceCreateSerializer
return DeviceSerializer
def create(self, request):
"""Register a new device via device_manager API."""
serializer = DeviceCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
response = device_manager.register_device(
name=serializer.validated_data['name'],
location=serializer.validated_data.get('location'),
protocol=serializer.validated_data.get('protocol', 'mqtt'),
connection_config=serializer.validated_data.get('connection_config'),
)
# Return full registration response with credentials
return Response({
'device_id': response.device_id,
'protocol': response.protocol,
'certificate_id': response.certificate_id,
'ca_certificate_pem': response.ca_certificate_pem,
'certificate_pem': response.certificate_pem,
'private_key_pem': response.private_key_pem,
'expires_at': response.expires_at.isoformat() if response.expires_at else None,
}, status=status.HTTP_201_CREATED)
except DeviceManagerAPIError as e:
return Response(
{'error': e.message, 'details': e.details},
status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR
)
def destroy(self, request, pk=None):
"""Delete a device."""
try:
device = self.get_object()
device_name = device.name
device.delete()
return Response(
{'message': f"Device '{device_name}' deleted successfully"},
status=status.HTTP_204_NO_CONTENT
)
except Device.DoesNotExist:
return Response(
{'error': 'Device not found'},
status=status.HTTP_404_NOT_FOUND
)
@action(detail=True, methods=['post'])
def revoke(self, request, pk=None):
"""Revoke a device's certificate."""
device = self.get_object()
if device.protocol != 'mqtt':
return Response(
{'error': 'Only MQTT devices have certificates to revoke'},
status=status.HTTP_400_BAD_REQUEST
)
try:
result = device_manager.revoke_certificate(device.id)
return Response(result)
except DeviceManagerAPIError as e:
return Response(
{'error': e.message, 'details': e.details},
status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=True, methods=['post'])
def renew(self, request, pk=None):
"""Renew a device's certificate."""
device = self.get_object()
if device.protocol != 'mqtt':
return Response(
{'error': 'Only MQTT devices have certificates to renew'},
status=status.HTTP_400_BAD_REQUEST
)
try:
response = device_manager.renew_certificate(device.id)
return Response({
'device_id': response.device_id,
'protocol': response.protocol,
'certificate_id': response.certificate_id,
'ca_certificate_pem': response.ca_certificate_pem,
'certificate_pem': response.certificate_pem,
'private_key_pem': response.private_key_pem,
'expires_at': response.expires_at.isoformat() if response.expires_at else None,
})
except DeviceManagerAPIError as e:
return Response(
{'error': e.message, 'details': e.details},
status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=True, methods=['get'])
def telemetry(self, request, pk=None):
"""Get telemetry data for a specific device."""
device = self.get_object()
# Parse query parameters
metric = request.query_params.get('metric')
hours = int(request.query_params.get('hours', 24))
limit = int(request.query_params.get('limit', 1000))
# Build query
queryset = Telemetry.objects.filter(
device_id=device.id,
time__gte=timezone.now() - timedelta(hours=hours)
)
if metric:
queryset = queryset.filter(metric=metric)
queryset = queryset.order_by('-time')[:limit]
serializer = TelemetrySerializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=['get'])
def metrics(self, request, pk=None):
"""Get available metrics for a device."""
device = self.get_object()
metrics = (
Telemetry.objects
.filter(device_id=device.id)
.values_list('metric', flat=True)
.distinct()
)
return Response({
'device_id': device.id,
'device_name': device.name,
'metrics': list(metrics)
})
class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
"""ViewSet for telemetry data queries."""
queryset = Telemetry.objects.all()
serializer_class = TelemetrySerializer
# permission_classes = [IsAuthenticated]
def get_queryset(self):
"""Filter telemetry by query parameters."""
queryset = Telemetry.objects.all()
# Filter by device
device_id = self.request.query_params.get('device_id')
if device_id:
queryset = queryset.filter(device_id=device_id)
# Filter by metric
metric = self.request.query_params.get('metric')
if metric:
queryset = queryset.filter(metric=metric)
# Filter by time range
hours = self.request.query_params.get('hours')
if hours:
queryset = queryset.filter(
time__gte=timezone.now() - timedelta(hours=int(hours))
)
start_time = self.request.query_params.get('start_time')
if start_time:
queryset = queryset.filter(time__gte=start_time)
end_time = self.request.query_params.get('end_time')
if end_time:
queryset = queryset.filter(time__lte=end_time)
return queryset.order_by('-time')
@action(detail=False, methods=['get'])
def latest(self, request):
"""Get latest telemetry readings for all devices."""
from django.db.models import Max
# Get latest timestamp for each device-metric combination
latest_readings = (
Telemetry.objects
.values('device_id', 'metric')
.annotate(latest_time=Max('time'))
)
# Fetch the actual records
telemetry = []
for reading in latest_readings:
record = Telemetry.objects.get(
device_id=reading['device_id'],
metric=reading['metric'],
time=reading['latest_time']
)
telemetry.append(record)
serializer = self.get_serializer(telemetry, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def metrics(self, request):
"""Get list of all available metrics."""
metrics = (
Telemetry.objects
.values_list('metric', flat=True)
.distinct()
)
return Response({'metrics': list(metrics)})
class DashboardViewSet(viewsets.ViewSet):
"""ViewSet for dashboard overview data."""
# permission_classes = [IsAuthenticated]
@action(detail=False, methods=['get'])
def overview(self, request):
"""Get dashboard overview statistics."""
# Device statistics
total_devices = Device.objects.count()
active_devices = Device.objects.filter(is_active=True).count()
mqtt_devices = Device.objects.filter(protocol='mqtt').count()
http_devices = Device.objects.filter(protocol__in=['http', 'webhook']).count()
# Certificate statistics
expiring_soon = DeviceCertificate.objects.filter(
revoked_at__isnull=True,
expires_at__lte=timezone.now() + timedelta(days=30),
expires_at__gt=timezone.now()
).count()
# Recent telemetry (last 10 readings)
recent_telemetry = Telemetry.objects.order_by('-time')[:10]
# Devices with their metrics
devices = Device.objects.all()
devices_with_metrics = []
for device in devices:
metrics = (
Telemetry.objects
.filter(device_id=device.id)
.values_list('metric', flat=True)
.distinct()
)
devices_with_metrics.append({
'device_id': device.id,
'device_name': device.name,
'metrics': list(metrics)
})
data = {
'total_devices': total_devices,
'active_devices': active_devices,
'mqtt_devices': mqtt_devices,
'http_devices': http_devices,
'certificates_expiring_soon': expiring_soon,
'recent_telemetry': TelemetrySerializer(recent_telemetry, many=True).data,
'devices_with_metrics': devices_with_metrics,
}
serializer = DashboardOverviewSerializer(data)
return Response(serializer.data)

View File

@@ -152,12 +152,14 @@ class DeviceCredential(models.Model):
class Telemetry(models.Model): class Telemetry(models.Model):
"""Time-series telemetry data from devices.""" """Time-series telemetry data from devices.
Note: This table has a composite primary key (time, device_id, metric).
We mark time as primary_key to prevent Django from adding an 'id' field.
"""
time = models.DateTimeField() time = models.DateTimeField(primary_key=True)
device = models.ForeignKey( device_id = models.CharField(max_length=8, db_column="device_id")
Device, on_delete=models.CASCADE, related_name="telemetry", db_column="device_id"
)
metric = models.CharField(max_length=255) metric = models.CharField(max_length=255)
value = models.FloatField() value = models.FloatField()
unit = models.CharField(max_length=50, null=True, blank=True) unit = models.CharField(max_length=50, null=True, blank=True)
@@ -165,11 +167,25 @@ class Telemetry(models.Model):
class Meta: class Meta:
managed = False managed = False
db_table = "telemetry" db_table = "telemetry"
unique_together = [["time", "device", "metric"]] # Django doesn't support composite PKs, so we can't specify all three
# The actual table has (time, device_id, metric) as composite PK
indexes = [ indexes = [
models.Index(fields=["device", "time"]), models.Index(fields=["device_id", "time"]),
] ]
def __str__(self): def __str__(self):
return f"{self.device.name} - {self.metric}: {self.value} at {self.time}" return f"{self.device_id} - {self.metric}: {self.value} at {self.time}"
@property
def device(self):
"""Lazy load device if needed."""
if not hasattr(self, '_device_cache'):
self._device_cache = Device.objects.filter(id=self.device_id).first()
return self._device_cache
@property
def device_name(self):
"""Get device name without full object load."""
device = self.device
return device.name if device else self.device_id

View File

@@ -48,11 +48,16 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
# Third-party apps
"rest_framework",
"corsheaders",
# Local apps
"iotDashboard", "iotDashboard",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware", # CORS before CommonMiddleware
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@@ -139,3 +144,67 @@ STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Django REST Framework settings
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 100,
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
# 'rest_framework_simplejwt.authentication.JWTAuthentication', # Enable for JWT
],
'DEFAULT_PERMISSION_CLASSES': [
# 'rest_framework.permissions.IsAuthenticated', # Enable for production
'rest_framework.permissions.AllowAny', # Development only
],
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer', # Nice for development
],
}
# CORS settings for React frontend
CORS_ALLOWED_ORIGINS = [
"http://localhost:5173", # Vite default port
"http://127.0.0.1:5173",
"http://localhost:3000", # Alternative React port
"http://127.0.0.1:3000",
]
CORS_ALLOW_CREDENTIALS = True # Allow cookies for session auth
# Additional CORS settings for proper header handling
CORS_ALLOW_METHODS = [
'DELETE',
'GET',
'OPTIONS',
'PATCH',
'POST',
'PUT',
]
CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
]
# Allow all origins for development (comment out for production)
# CORS_ALLOW_ALL_ORIGINS = True
# CSRF settings for React
CSRF_TRUSTED_ORIGINS = [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
# Device Manager API URL
DEVICE_MANAGER_URL = os.getenv("DEVICE_MANAGER_URL", "http://localhost:8000")

View File

@@ -16,12 +16,15 @@ Including another URLconf
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path, include
from iotDashboard import views from iotDashboard import views
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
# REST API
path("api/", include("iotDashboard.api.urls")),
# Main dashboard # Main dashboard
path("", views.chart, name="index"), path("", views.chart, name="index"),
path("chart/", views.chart, name="chart"), path("chart/", views.chart, name="chart"),

View File

@@ -7,6 +7,10 @@ requires-python = ">=3.13"
dependencies = [ dependencies = [
"alembic>=1.17.0", "alembic>=1.17.0",
"django>=5.2.7", "django>=5.2.7",
"django-cors-headers>=4.9.0",
"django-rest>=0.8.7",
"djangorestframework>=3.16.1",
"djangorestframework-simplejwt>=5.5.1",
"openai>=2.6.1", "openai>=2.6.1",
"paho-mqtt>=2.1.0", "paho-mqtt>=2.1.0",
"psycopg2-binary>=2.9.11", "psycopg2-binary>=2.9.11",

75
uv.lock generated
View File

@@ -157,6 +157,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" }, { url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
] ]
[[package]]
name = "django-cors-headers"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
]
[[package]]
name = "django-rest"
version = "0.8.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d6/b4/1cdc1632448e252e37335427ec2a8dce470ef0c8dae066c3e3809484b2ac/django-rest-0.8.7.tar.gz", hash = "sha256:24a0eca6aa53864affcab5a880173f701e5387ad4e5885e12c81184432d6e15b", size = 58364, upload-time = "2021-04-20T22:15:40.004Z" }
[[package]]
name = "djangorestframework"
version = "3.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
]
[[package]]
name = "djangorestframework-simplejwt"
version = "5.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
{ name = "pyjwt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
]
[[package]] [[package]]
name = "gpt-service" name = "gpt-service"
version = "0.1.0" version = "0.1.0"
@@ -249,6 +298,10 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "django" }, { name = "django" },
{ name = "django-cors-headers" },
{ name = "django-rest" },
{ name = "djangorestframework" },
{ name = "djangorestframework-simplejwt" },
{ name = "openai" }, { name = "openai" },
{ name = "paho-mqtt" }, { name = "paho-mqtt" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
@@ -266,6 +319,10 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.17.0" }, { name = "alembic", specifier = ">=1.17.0" },
{ name = "django", specifier = ">=5.2.7" }, { name = "django", specifier = ">=5.2.7" },
{ name = "django-cors-headers", specifier = ">=4.9.0" },
{ name = "django-rest", specifier = ">=0.8.7" },
{ name = "djangorestframework", specifier = ">=3.16.1" },
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
{ name = "openai", specifier = ">=2.6.1" }, { name = "openai", specifier = ">=2.6.1" },
{ name = "paho-mqtt", specifier = ">=2.1.0" }, { name = "paho-mqtt", specifier = ">=2.1.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "psycopg2-binary", specifier = ">=2.9.11" },
@@ -514,6 +571,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" }, { url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" },
] ]
[[package]]
name = "pyjwt"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" version = "1.2.1"
@@ -573,6 +639,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"