Initial Commit. Split cloud service from the combined project

This commit is contained in:
2026-05-08 13:20:23 +03:00
commit 4cbf8986df
37 changed files with 4543 additions and 0 deletions

View 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>
)
}