mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 09:06:26 +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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user