Initial Commit. Split cloud service from the combined project
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user