mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 01:06:24 +00:00
Started new initial React frontend, aadditinal changes for Django to run REST.
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
73
frontend/README.md
Normal 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
23
frontend/eslint.config.js
Normal 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
13
frontend/index.html
Normal 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
5815
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
frontend/package.json
Normal file
51
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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
89
frontend/src/App.css
Normal 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
97
frontend/src/App.tsx
Normal 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
402
frontend/src/App.tsx.bak
Normal 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
67
frontend/src/api/index.ts
Normal 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/'),
|
||||||
|
};
|
||||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal 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 |
136
frontend/src/components/CredentialsViewer.tsx
Normal file
136
frontend/src/components/CredentialsViewer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
frontend/src/components/DeleteDeviceDialog.tsx
Normal file
76
frontend/src/components/DeleteDeviceDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
frontend/src/components/DeviceCredentialsDialog.tsx
Normal file
40
frontend/src/components/DeviceCredentialsDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
frontend/src/components/RenewDialog.tsx
Normal file
101
frontend/src/components/RenewDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
frontend/src/components/RevokeDialog.tsx
Normal file
74
frontend/src/components/RevokeDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
frontend/src/components/dashboard/TelemetryTrendCard.tsx
Normal file
84
frontend/src/components/dashboard/TelemetryTrendCard.tsx
Normal 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
126
frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
frontend/src/lib/api-client.ts
Normal file
36
frontend/src/lib/api-client.ts
Normal 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
29
frontend/src/lib/utils.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
160
frontend/src/pages/AddDevice.tsx
Normal file
160
frontend/src/pages/AddDevice.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
401
frontend/src/pages/Dashboard.tsx
Normal file
401
frontend/src/pages/Dashboard.tsx
Normal 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
|
||||||
|
trends—device 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
187
frontend/src/pages/DeviceDetail.tsx
Normal file
187
frontend/src/pages/DeviceDetail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
frontend/src/pages/DeviceList.tsx
Normal file
157
frontend/src/pages/DeviceList.tsx
Normal 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
65
frontend/src/types/api.ts
Normal 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[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
23
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
1
iotDashboard/api/__init__.py
Normal file
1
iotDashboard/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""REST API for IoT Dashboard."""
|
||||||
79
iotDashboard/api/serializers.py
Normal file
79
iotDashboard/api/serializers.py
Normal 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
15
iotDashboard/api/urls.py
Normal 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
303
iotDashboard/api/views.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
75
uv.lock
generated
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user