Phase 5: scaffold Sysadmin Panel — all pages, layout, routing, docker-compose service
This commit is contained in:
@@ -51,3 +51,17 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
sysadmin_panel:
|
||||||
|
image: node:20-alpine
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./sysadmin_panel:/app
|
||||||
|
ports:
|
||||||
|
- "5175:5175"
|
||||||
|
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||||
|
env_file:
|
||||||
|
- ./sysadmin_panel/.env
|
||||||
|
depends_on:
|
||||||
|
- cloud_backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|||||||
12
sysadmin_panel/index.html
Normal file
12
sysadmin_panel/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>POS Sysadmin</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
sysadmin_panel/package.json
Normal file
26
sysadmin_panel/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "sysadmin-panel",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-router-dom": "^6.28.0",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
sysadmin_panel/postcss.config.js
Normal file
6
sysadmin_panel/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
81
sysadmin_panel/src/App.jsx
Normal file
81
sysadmin_panel/src/App.jsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import useAuthStore from './store/authStore'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import SitesPage from './pages/SitesPage'
|
||||||
|
import SiteDetailPage from './pages/SiteDetailPage'
|
||||||
|
import RegisterSitePage from './pages/RegisterSitePage'
|
||||||
|
|
||||||
|
function Layout({ children }) {
|
||||||
|
const { logout } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout()
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950">
|
||||||
|
<nav className="border-b border-gray-800 bg-gray-950/80 backdrop-blur sticky top-0 z-10">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/sites')}
|
||||||
|
className="flex items-center gap-2 text-white font-semibold text-sm"
|
||||||
|
>
|
||||||
|
<div className="w-6 h-6 bg-cyan-500/20 border border-cyan-500/40 rounded flex items-center justify-center">
|
||||||
|
<svg className="w-3 h-3 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
Sysadmin
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className={`text-xs px-2.5 py-1 rounded font-mono ${
|
||||||
|
location.pathname.startsWith('/sites') ? 'text-cyan-400 bg-cyan-500/10' : 'text-gray-500'
|
||||||
|
}`}>
|
||||||
|
Sites
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="max-w-5xl mx-auto px-4 py-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequireAuth({ children }) {
|
||||||
|
const { token } = useAuthStore()
|
||||||
|
if (!token) return <Navigate to="/login" replace />
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { token } = useAuthStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={token ? <Navigate to="/sites" replace /> : <LoginPage />} />
|
||||||
|
<Route path="/sites" element={
|
||||||
|
<RequireAuth><Layout><SitesPage /></Layout></RequireAuth>
|
||||||
|
} />
|
||||||
|
<Route path="/sites/register" element={
|
||||||
|
<RequireAuth><Layout><RegisterSitePage /></Layout></RequireAuth>
|
||||||
|
} />
|
||||||
|
<Route path="/sites/:siteId" element={
|
||||||
|
<RequireAuth><Layout><SiteDetailPage /></Layout></RequireAuth>
|
||||||
|
} />
|
||||||
|
<Route path="*" element={<Navigate to="/sites" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
sysadmin_panel/src/api/client.js
Normal file
24
sysadmin_panel/src/api/client.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const BASE_URL = import.meta.env.VITE_CLOUD_URL || 'http://localhost:8001'
|
||||||
|
|
||||||
|
const client = axios.create({ baseURL: BASE_URL })
|
||||||
|
|
||||||
|
client.interceptors.request.use(config => {
|
||||||
|
const token = localStorage.getItem('sysadmin_token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
res => res,
|
||||||
|
err => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('sysadmin_token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default client
|
||||||
29
sysadmin_panel/src/components/ConfirmModal.jsx
Normal file
29
sysadmin_panel/src/components/ConfirmModal.jsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export default function ConfirmModal({ title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel, children }) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md shadow-2xl">
|
||||||
|
<h2 className="text-white font-semibold text-lg mb-2">{title}</h2>
|
||||||
|
{message && <p className="text-gray-400 text-sm mb-4">{message}</p>}
|
||||||
|
{children}
|
||||||
|
<div className="flex gap-3 justify-end mt-5">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm text-gray-300 hover:text-white bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className={`px-4 py-2 text-sm font-semibold rounded-lg transition-colors ${
|
||||||
|
danger
|
||||||
|
? 'bg-red-600 hover:bg-red-500 text-white'
|
||||||
|
: 'bg-cyan-600 hover:bg-cyan-500 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
sysadmin_panel/src/components/LicenseStatus.jsx
Normal file
17
sysadmin_panel/src/components/LicenseStatus.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export default function LicenseStatus({ site }) {
|
||||||
|
const now = new Date()
|
||||||
|
const expires = new Date(site.license_expires_at)
|
||||||
|
const lastSeen = site.last_seen_at ? new Date(site.last_seen_at) : null
|
||||||
|
const hoursAgo = lastSeen ? (now - lastSeen) / 1000 / 3600 : null
|
||||||
|
|
||||||
|
if (site.is_locked) {
|
||||||
|
return <span className="inline-flex items-center gap-1.5 text-xs font-medium text-red-400"><span className="w-2 h-2 rounded-full bg-red-500" />Locked</span>
|
||||||
|
}
|
||||||
|
if (expires < now) {
|
||||||
|
return <span className="inline-flex items-center gap-1.5 text-xs font-medium text-red-400"><span className="w-2 h-2 rounded-full bg-red-500" />Expired</span>
|
||||||
|
}
|
||||||
|
if (hoursAgo === null || hoursAgo > 12) {
|
||||||
|
return <span className="inline-flex items-center gap-1.5 text-xs font-medium text-yellow-400"><span className="w-2 h-2 rounded-full bg-yellow-400" />No Heartbeat</span>
|
||||||
|
}
|
||||||
|
return <span className="inline-flex items-center gap-1.5 text-xs font-medium text-emerald-400"><span className="w-2 h-2 rounded-full bg-emerald-400" />Active</span>
|
||||||
|
}
|
||||||
64
sysadmin_panel/src/components/SiteCard.jsx
Normal file
64
sysadmin_panel/src/components/SiteCard.jsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import LicenseStatus from './LicenseStatus'
|
||||||
|
|
||||||
|
function fmtDate(dt) {
|
||||||
|
if (!dt) return '—'
|
||||||
|
return new Date(dt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtAgo(dt) {
|
||||||
|
if (!dt) return 'Never'
|
||||||
|
const diff = (Date.now() - new Date(dt)) / 1000
|
||||||
|
if (diff < 60) return 'Just now'
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteCard({ site }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const expires = new Date(site.license_expires_at)
|
||||||
|
const isExpired = expires < new Date()
|
||||||
|
const isLocked = site.is_locked
|
||||||
|
|
||||||
|
const borderColor = isLocked || isExpired
|
||||||
|
? 'border-red-800/60 hover:border-red-700'
|
||||||
|
: site.last_seen_at && (Date.now() - new Date(site.last_seen_at)) / 3600000 < 12
|
||||||
|
? 'border-emerald-800/40 hover:border-emerald-600'
|
||||||
|
: 'border-yellow-800/40 hover:border-yellow-600'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => navigate(`/sites/${site.site_id}`)}
|
||||||
|
className={`bg-gray-900 border ${borderColor} rounded-xl p-4 cursor-pointer transition-all hover:bg-gray-800`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-white font-semibold text-sm truncate">{site.name}</h3>
|
||||||
|
<p className="text-gray-500 text-xs truncate">{site.owner_name}</p>
|
||||||
|
</div>
|
||||||
|
<LicenseStatus site={site} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Expires</span>
|
||||||
|
<p className={`font-medium ${isExpired ? 'text-red-400' : 'text-gray-300'}`}>{fmtDate(site.license_expires_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Last seen</span>
|
||||||
|
<p className="text-gray-300 font-medium">{fmtAgo(site.last_seen_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 mt-1">
|
||||||
|
<span className="text-gray-600 font-mono text-[10px]">{site.site_id}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLocked && site.lock_reason && (
|
||||||
|
<p className="mt-2 text-xs text-red-400 bg-red-900/20 border border-red-800/30 rounded px-2 py-1 truncate">
|
||||||
|
Locked: {site.lock_reason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
sysadmin_panel/src/index.css
Normal file
8
sysadmin_panel/src/index.css
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #030712;
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
25
sysadmin_panel/src/main.jsx
Normal file
25
sysadmin_panel/src/main.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
background: '#1f2937',
|
||||||
|
color: '#f9fafb',
|
||||||
|
border: '1px solid #374151',
|
||||||
|
fontSize: '14px',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
89
sysadmin_panel/src/pages/LoginPage.jsx
Normal file
89
sysadmin_panel/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import useAuthStore from '../store/authStore'
|
||||||
|
import client from '../api/client'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { login } = useAuthStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!username.trim() || !password) return
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await client.post('/api/auth/login', {
|
||||||
|
username: username.trim(),
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
login(data.access_token)
|
||||||
|
navigate('/sites', { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Invalid credentials')
|
||||||
|
setPassword('')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 w-full max-w-sm shadow-2xl">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 bg-cyan-500/10 border border-cyan-500/30 rounded-lg mb-4">
|
||||||
|
<svg className="w-6 h-6 text-cyan-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
<h1 className="text-xl font-bold text-white">POS Sysadmin</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">Cloud Control Panel</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600"
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-sm bg-red-900/20 border border-red-800/50 rounded-lg px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !username.trim() || !password}
|
||||||
|
className="w-full bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold py-2.5 rounded-lg text-sm transition-colors mt-2"
|
||||||
|
>
|
||||||
|
{loading ? 'Authenticating…' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
sysadmin_panel/src/pages/RegisterSitePage.jsx
Normal file
196
sysadmin_panel/src/pages/RegisterSitePage.jsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import client from '../api/client'
|
||||||
|
|
||||||
|
export default function RegisterSitePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
owner_name: '',
|
||||||
|
contact_email: '',
|
||||||
|
license_expires_at: '',
|
||||||
|
})
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [created, setCreated] = useState(null) // { site_id, secret_key, name }
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
function update(k, v) {
|
||||||
|
setForm(f => ({ ...f, [k]: v }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!form.name.trim() || !form.owner_name.trim() || !form.contact_email.trim() || !form.license_expires_at) return
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const { data } = await client.post('/api/sites/', {
|
||||||
|
name: form.name.trim(),
|
||||||
|
owner_name: form.owner_name.trim(),
|
||||||
|
contact_email: form.contact_email.trim(),
|
||||||
|
license_expires_at: new Date(form.license_expires_at).toISOString(),
|
||||||
|
})
|
||||||
|
setCreated(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.detail || 'Registration failed')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyAll() {
|
||||||
|
const text = `SITE_ID=${created.site_id}\nSITE_SECRET=${created.secret_key}`
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg">
|
||||||
|
<div className="bg-emerald-900/20 border border-emerald-700/50 rounded-xl p-6 mb-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 bg-emerald-500/20 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-white font-semibold">Site Registered: {created.name}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-900/30 border border-yellow-700/50 rounded-lg px-4 py-3 mb-5 flex items-start gap-2">
|
||||||
|
<svg className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-yellow-300 text-sm font-medium">
|
||||||
|
Copy these credentials now. The secret key will <strong>never</strong> be shown again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-5">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 uppercase tracking-wider block mb-1">Site ID</label>
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 font-mono text-sm text-cyan-300 select-all break-all">
|
||||||
|
{created.site_id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-500 uppercase tracking-wider block mb-1">Secret Key</label>
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 font-mono text-sm text-cyan-300 select-all break-all">
|
||||||
|
{created.secret_key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500 mb-5 bg-gray-900/60 border border-gray-700 rounded-lg px-3 py-2">
|
||||||
|
<p className="font-medium text-gray-400 mb-1">Set these in the local backend <code>.env</code>:</p>
|
||||||
|
<pre className="text-gray-300 font-mono whitespace-pre-wrap">SITE_ID={created.site_id}{'\n'}SITE_SECRET={created.secret_key}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={copyAll}
|
||||||
|
className="flex items-center gap-2 bg-cyan-700 hover:bg-cyan-600 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{copied ? 'Copied!' : 'Copy .env vars'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/sites/${created.site_id}`)}
|
||||||
|
className="text-sm text-gray-400 hover:text-white px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
View Site →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/sites')}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
← Back to all sites
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/sites')}
|
||||||
|
className="flex items-center gap-1.5 text-gray-500 hover:text-gray-300 text-sm mb-6 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
All Sites
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="text-xl font-bold text-white mb-6">Register New Site</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">Restaurant Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => update('name', e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600"
|
||||||
|
placeholder="e.g. Taverna Kostas"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">Owner Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.owner_name}
|
||||||
|
onChange={e => update('owner_name', e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600"
|
||||||
|
placeholder="e.g. Kostas Papadopoulos"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">Contact Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.contact_email}
|
||||||
|
onChange={e => update('contact_email', e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600"
|
||||||
|
placeholder="kostas@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-gray-400 mb-1.5 uppercase tracking-wider">License Expiry</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.license_expires_at}
|
||||||
|
onChange={e => update('license_expires_at', e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-sm bg-red-900/20 border border-red-800/50 rounded-lg px-3 py-2">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-cyan-600 hover:bg-cyan-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-semibold py-2.5 rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Registering…' : 'Register Site'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
293
sysadmin_panel/src/pages/SiteDetailPage.jsx
Normal file
293
sysadmin_panel/src/pages/SiteDetailPage.jsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import client from '../api/client'
|
||||||
|
import LicenseStatus from '../components/LicenseStatus'
|
||||||
|
import ConfirmModal from '../components/ConfirmModal'
|
||||||
|
|
||||||
|
function fmtDt(dt) {
|
||||||
|
if (!dt) return '—'
|
||||||
|
return new Date(dt).toLocaleString('en-GB', {
|
||||||
|
day: '2-digit', month: 'short', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(dt) {
|
||||||
|
if (!dt) return ''
|
||||||
|
return new Date(dt).toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteDetailPage() {
|
||||||
|
const { siteId } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [site, setSite] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license'
|
||||||
|
const [lockReason, setLockReason] = useState('')
|
||||||
|
const [newExpiry, setNewExpiry] = useState('')
|
||||||
|
const [acting, setActing] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const { data } = await client.get(`/api/sites/${siteId}`)
|
||||||
|
setSite(data)
|
||||||
|
setNewExpiry(fmtDate(data.license_expires_at))
|
||||||
|
} catch {
|
||||||
|
setError('Site not found')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
async function doLock() {
|
||||||
|
if (!lockReason.trim()) return
|
||||||
|
setActing(true)
|
||||||
|
try {
|
||||||
|
const { data } = await client.post(`/api/sites/${siteId}/lock`, { reason: lockReason.trim() })
|
||||||
|
setSite(data)
|
||||||
|
setModal(null)
|
||||||
|
setLockReason('')
|
||||||
|
toast.success('Site locked')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e.response?.data?.detail || 'Failed to lock site')
|
||||||
|
} finally {
|
||||||
|
setActing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUnlock() {
|
||||||
|
setActing(true)
|
||||||
|
try {
|
||||||
|
const { data } = await client.post(`/api/sites/${siteId}/unlock`)
|
||||||
|
setSite(data)
|
||||||
|
setModal(null)
|
||||||
|
toast.success('Site unlocked')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e.response?.data?.detail || 'Failed to unlock site')
|
||||||
|
} finally {
|
||||||
|
setActing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doExtendLicense() {
|
||||||
|
if (!newExpiry) return
|
||||||
|
setActing(true)
|
||||||
|
try {
|
||||||
|
const { data } = await client.put(`/api/sites/${siteId}`, {
|
||||||
|
license_expires_at: new Date(newExpiry).toISOString(),
|
||||||
|
})
|
||||||
|
setSite(data)
|
||||||
|
setModal(null)
|
||||||
|
toast.success('License updated')
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e.response?.data?.detail || 'Failed to update license')
|
||||||
|
} finally {
|
||||||
|
setActing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
setActing(true)
|
||||||
|
try {
|
||||||
|
await client.delete(`/api/sites/${siteId}`)
|
||||||
|
toast.success('Site deregistered')
|
||||||
|
navigate('/sites', { replace: true })
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e.response?.data?.detail || 'Failed to delete site')
|
||||||
|
setActing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center py-16 text-gray-600">Loading…</div>
|
||||||
|
if (error || !site) return <div className="text-red-400 py-8">{error || 'Not found'}</div>
|
||||||
|
|
||||||
|
const expires = new Date(site.license_expires_at)
|
||||||
|
const isExpired = expires < new Date()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/sites')}
|
||||||
|
className="flex items-center gap-1.5 text-gray-500 hover:text-gray-300 text-sm mb-6 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
All Sites
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white">{site.name}</h1>
|
||||||
|
<p className="text-gray-500 text-sm">{site.owner_name} · {site.contact_email}</p>
|
||||||
|
</div>
|
||||||
|
<LicenseStatus site={site} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Site info */}
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mb-4">
|
||||||
|
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Site Info</h2>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Site ID</span>
|
||||||
|
<span className="text-gray-300 font-mono text-xs">{site.site_id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Registered</span>
|
||||||
|
<span className="text-gray-300">{fmtDt(site.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Contact</span>
|
||||||
|
<span className="text-gray-300">{site.contact_email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* License */}
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">License</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setModal('license')}
|
||||||
|
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
|
||||||
|
>
|
||||||
|
Extend →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Expires</span>
|
||||||
|
<span className={`font-medium ${isExpired ? 'text-red-400' : 'text-gray-300'}`}>
|
||||||
|
{fmtDt(site.license_expires_at)}
|
||||||
|
{isExpired && ' (EXPIRED)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heartbeat */}
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mb-4">
|
||||||
|
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Heartbeat</h2>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Last seen</span>
|
||||||
|
<span className="text-gray-300">{fmtDt(site.last_seen_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Last IP</span>
|
||||||
|
<span className="text-gray-300 font-mono text-xs">{site.last_seen_ip || '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lock status */}
|
||||||
|
{site.is_locked && (
|
||||||
|
<div className="bg-red-900/20 border border-red-800/50 rounded-xl p-4 mb-4">
|
||||||
|
<h2 className="text-xs font-semibold text-red-500 uppercase tracking-wider mb-2">Locked</h2>
|
||||||
|
<p className="text-red-300 text-sm">{site.lock_reason || 'No reason given'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4">
|
||||||
|
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{site.is_locked ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setModal('unlock')}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-emerald-700 hover:bg-emerald-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Unlock Site
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setModal('lock')}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-yellow-700 hover:bg-yellow-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Lock Site
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { setNewExpiry(fmtDate(site.license_expires_at)); setModal('license') }}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-cyan-700 hover:bg-cyan-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Extend License
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setModal('delete')}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-red-900/50 hover:bg-red-800 border border-red-700 text-red-300 rounded-lg transition-colors ml-auto"
|
||||||
|
>
|
||||||
|
Deregister Site
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
{modal === 'lock' && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Lock Site"
|
||||||
|
confirmLabel={acting ? 'Locking…' : 'Lock Site'}
|
||||||
|
danger
|
||||||
|
onCancel={() => { setModal(null); setLockReason('') }}
|
||||||
|
onConfirm={doLock}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
value={lockReason}
|
||||||
|
onChange={e => setLockReason(e.target.value)}
|
||||||
|
placeholder="Reason for locking (required)"
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-red-500 mb-2 resize-none placeholder-gray-600"
|
||||||
|
/>
|
||||||
|
</ConfirmModal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modal === 'unlock' && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Unlock Site"
|
||||||
|
message={`Unlock "${site.name}"? The site will be able to connect again.`}
|
||||||
|
confirmLabel={acting ? 'Unlocking…' : 'Unlock Site'}
|
||||||
|
onCancel={() => setModal(null)}
|
||||||
|
onConfirm={doUnlock}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modal === 'license' && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Extend License"
|
||||||
|
confirmLabel={acting ? 'Saving…' : 'Save'}
|
||||||
|
onCancel={() => setModal(null)}
|
||||||
|
onConfirm={doExtendLicense}
|
||||||
|
>
|
||||||
|
<div className="mb-2">
|
||||||
|
<label className="block text-xs text-gray-400 mb-1.5">New expiry date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={newExpiry}
|
||||||
|
onChange={e => setNewExpiry(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ConfirmModal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modal === 'delete' && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Deregister Site"
|
||||||
|
message={`This will permanently remove "${site.name}" from the system. The local backend will lose its license on the next heartbeat. This cannot be undone.`}
|
||||||
|
confirmLabel={acting ? 'Deleting…' : 'Deregister'}
|
||||||
|
danger
|
||||||
|
onCancel={() => setModal(null)}
|
||||||
|
onConfirm={doDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
sysadmin_panel/src/pages/SitesPage.jsx
Normal file
97
sysadmin_panel/src/pages/SitesPage.jsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import client from '../api/client'
|
||||||
|
import SiteCard from '../components/SiteCard'
|
||||||
|
|
||||||
|
export default function SitesPage() {
|
||||||
|
const [sites, setSites] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const fetchSites = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await client.get('/api/sites/')
|
||||||
|
setSites(data)
|
||||||
|
setError('')
|
||||||
|
} catch {
|
||||||
|
setError('Failed to load sites')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSites()
|
||||||
|
const id = setInterval(fetchSites, 30000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [fetchSites])
|
||||||
|
|
||||||
|
const active = sites.filter(s => !s.is_locked && new Date(s.license_expires_at) > new Date()).length
|
||||||
|
const locked = sites.filter(s => s.is_locked).length
|
||||||
|
const expired = sites.filter(s => !s.is_locked && new Date(s.license_expires_at) <= new Date()).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white">Sites</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-0.5">{sites.length} registered</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/sites/register')}
|
||||||
|
className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-semibold px-4 py-2 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Register New Site
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary row */}
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-emerald-400">{active}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5">Active</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-yellow-400">{locked}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5">Locked</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-red-400">{expired}</p>
|
||||||
|
<p className="text-gray-500 text-xs mt-0.5">Expired</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-16 text-gray-600">Loading…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-400 bg-red-900/20 border border-red-800/50 rounded-lg px-4 py-3 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && sites.length === 0 && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<p className="text-gray-600 text-sm">No sites registered yet.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/sites/register')}
|
||||||
|
className="mt-4 text-cyan-400 hover:text-cyan-300 text-sm underline"
|
||||||
|
>
|
||||||
|
Register your first site
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{sites.map(site => (
|
||||||
|
<SiteCard key={site.site_id} site={site} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
sysadmin_panel/src/store/authStore.js
Normal file
17
sysadmin_panel/src/store/authStore.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
const useAuthStore = create((set) => ({
|
||||||
|
token: localStorage.getItem('sysadmin_token') || null,
|
||||||
|
|
||||||
|
login(token) {
|
||||||
|
localStorage.setItem('sysadmin_token', token)
|
||||||
|
set({ token })
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
localStorage.removeItem('sysadmin_token')
|
||||||
|
set({ token: null })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export default useAuthStore
|
||||||
17
sysadmin_panel/tailwind.config.js
Normal file
17
sysadmin_panel/tailwind.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
cyan: {
|
||||||
|
400: '#22d3ee',
|
||||||
|
500: '#06b6d4',
|
||||||
|
600: '#0891b2',
|
||||||
|
700: '#0e7490',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
10
sysadmin_panel/vite.config.js
Normal file
10
sysadmin_panel/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5175,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user