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):
|
||||
"""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()
|
||||
device = models.ForeignKey(
|
||||
Device, on_delete=models.CASCADE, related_name="telemetry", db_column="device_id"
|
||||
)
|
||||
time = models.DateTimeField(primary_key=True)
|
||||
device_id = models.CharField(max_length=8, db_column="device_id")
|
||||
metric = models.CharField(max_length=255)
|
||||
value = models.FloatField()
|
||||
unit = models.CharField(max_length=50, null=True, blank=True)
|
||||
@@ -165,11 +167,25 @@ class Telemetry(models.Model):
|
||||
class Meta:
|
||||
managed = False
|
||||
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 = [
|
||||
models.Index(fields=["device", "time"]),
|
||||
models.Index(fields=["device_id", "time"]),
|
||||
]
|
||||
|
||||
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.messages",
|
||||
"django.contrib.staticfiles",
|
||||
# Third-party apps
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
# Local apps
|
||||
"iotDashboard",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware", # CORS before CommonMiddleware
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -139,3 +144,67 @@ STATIC_URL = "static/"
|
||||
|
||||
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.urls import path
|
||||
from django.urls import path, include
|
||||
from iotDashboard import views
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
|
||||
# REST API
|
||||
path("api/", include("iotDashboard.api.urls")),
|
||||
|
||||
# Main dashboard
|
||||
path("", views.chart, name="index"),
|
||||
path("chart/", views.chart, name="chart"),
|
||||
|
||||
@@ -7,6 +7,10 @@ requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"alembic>=1.17.0",
|
||||
"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",
|
||||
"paho-mqtt>=2.1.0",
|
||||
"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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "gpt-service"
|
||||
version = "0.1.0"
|
||||
@@ -249,6 +298,10 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "django" },
|
||||
{ name = "django-cors-headers" },
|
||||
{ name = "django-rest" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "djangorestframework-simplejwt" },
|
||||
{ name = "openai" },
|
||||
{ name = "paho-mqtt" },
|
||||
{ name = "psycopg2-binary" },
|
||||
@@ -266,6 +319,10 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.17.0" },
|
||||
{ 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 = "paho-mqtt", specifier = ">=2.1.0" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "python-dotenv"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
|
||||
Reference in New Issue
Block a user