git init
This commit is contained in:
29
control-plane/frontend/src/App.test.tsx
Normal file
29
control-plane/frontend/src/App.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { App } from './App'
|
||||
|
||||
const renderApp = () => render(<QueryClientProvider client={new QueryClient({defaultOptions:{queries:{retry:false}}})}><App/></QueryClientProvider>)
|
||||
|
||||
describe('main screens', () => {
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks() })
|
||||
|
||||
it('shows login when there is no session', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({error:'authentication required'}), {status:401,headers:{'Content-Type':'application/json'}})))
|
||||
renderApp()
|
||||
expect(await screen.findByRole('heading',{name:'Infrastructure, in focus.'})).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Username')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the empty fleet state for an administrator', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn((input:RequestInfo|URL) => {
|
||||
const url=String(input)
|
||||
const value=url.endsWith('/auth/me')?{id:'1',username:'admin',role:'administrator'}:[]
|
||||
return Promise.resolve(new Response(JSON.stringify(value),{status:200,headers:{'Content-Type':'application/json'}}))
|
||||
}))
|
||||
renderApp()
|
||||
await waitFor(()=>expect(screen.getByText('No servers enrolled')).toBeInTheDocument())
|
||||
expect(screen.getByText('admin')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
248
control-plane/frontend/src/App.tsx
Normal file
248
control-plane/frontend/src/App.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { FormEvent, ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Activity, Ban, Boxes, Cable, ChevronDown, CircleGauge, FileClock, Shield, Gauge,
|
||||
KeyRound, ListRestart, LogOut, Menu, Moon, Network, Plus, RadioTower, Server as ServerIcon,
|
||||
Settings, ShieldAlert, Sun, Unplug, X,
|
||||
} from 'lucide-react'
|
||||
import { api, APIError, formatBytes, formatDuration } from './api'
|
||||
import type {
|
||||
AgentLogEntry, AuditEvent, FirewallStatus, Interface, JailStatus, MetricSample, Role, Server, Service,
|
||||
SocketInfo, SystemStatus, User, WireGuardInterface,
|
||||
} from './types'
|
||||
|
||||
type Page = 'Overview' | 'Servers' | 'Network' | 'Firewall' | 'Connections' | 'Security' |
|
||||
'Fail2Ban' | 'WireGuard' | 'Services' | 'Logs' | 'Rate Limits' | 'Audit' | 'Settings'
|
||||
|
||||
const navigation: Array<{name: Page; icon: typeof Activity}> = [
|
||||
{ name: 'Overview', icon: CircleGauge }, { name: 'Servers', icon: ServerIcon },
|
||||
{ name: 'Network', icon: Network }, { name: 'Firewall', icon: Shield },
|
||||
{ name: 'Connections', icon: Cable }, { name: 'Security', icon: ShieldAlert },
|
||||
{ name: 'Fail2Ban', icon: Ban }, { name: 'WireGuard', icon: RadioTower },
|
||||
{ name: 'Services', icon: Boxes }, { name: 'Logs', icon: FileClock },
|
||||
{ name: 'Rate Limits', icon: Gauge }, { name: 'Audit', icon: FileClock },
|
||||
{ name: 'Settings', icon: Settings },
|
||||
]
|
||||
|
||||
export function App() {
|
||||
const queryClient = useQueryClient()
|
||||
const me = useQuery<User>({ queryKey: ['me'], queryFn: () => api('/auth/me'), retry: false })
|
||||
const servers = useQuery<Server[]>({ queryKey: ['servers'], queryFn: () => api('/servers'), enabled: me.isSuccess })
|
||||
const [page, setPage] = useState<Page>('Overview')
|
||||
const [selectedID, setSelectedID] = useState('')
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [dark, setDark] = useState(() => localStorage.getItem('theme') !== 'light')
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||
localStorage.setItem('theme', dark ? 'dark' : 'light')
|
||||
}, [dark])
|
||||
useEffect(() => {
|
||||
if (!selectedID && servers.data?.[0]) setSelectedID(servers.data[0].id)
|
||||
}, [selectedID, servers.data])
|
||||
|
||||
if (me.isPending) return <LoadingScreen />
|
||||
if (me.isError) return <Login onSuccess={() => void queryClient.invalidateQueries({ queryKey: ['me'] })} />
|
||||
|
||||
const selected = servers.data?.find((server) => server.id === selectedID)
|
||||
const choosePage = (next: Page) => { setPage(next); setMobileOpen(false) }
|
||||
return <div className="shell">
|
||||
<aside className={`sidebar ${mobileOpen ? 'open' : ''}`}>
|
||||
<div className="brand"><div className="brand-mark"><Activity size={20}/></div><span>VPS Control</span><button className="mobile-close" onClick={() => setMobileOpen(false)}><X/></button></div>
|
||||
<div className="server-select-wrap">
|
||||
<label>ACTIVE SERVER</label>
|
||||
<div className="select-shell"><StatusDot status={selected?.status ?? 'unknown'}/><select aria-label="Active server" value={selectedID} onChange={(event) => setSelectedID(event.target.value)}>
|
||||
{!servers.data?.length && <option value="">No servers</option>}
|
||||
{servers.data?.map((server) => <option value={server.id} key={server.id}>{server.name}</option>)}
|
||||
</select><ChevronDown size={14}/></div>
|
||||
</div>
|
||||
<nav>{navigation.map(({name, icon: Icon}) => <button key={name} className={page === name ? 'active' : ''} onClick={() => choosePage(name)}><Icon size={17}/><span>{name}</span></button>)}</nav>
|
||||
<div className="sidebar-foot"><span className="eyebrow">SIGNED IN AS</span><strong>{me.data.username}</strong><span>{me.data.role}</span></div>
|
||||
</aside>
|
||||
{mobileOpen && <button className="scrim" onClick={() => setMobileOpen(false)} aria-label="Close navigation"/>}
|
||||
<main>
|
||||
<header className="topbar">
|
||||
<button className="menu-button" onClick={() => setMobileOpen(true)}><Menu/></button>
|
||||
<div><span className="breadcrumb">VPS CONTROL / </span><strong>{page.toUpperCase()}</strong></div>
|
||||
<div className="top-actions"><div className={`connection ${selected?.status ?? 'unknown'}`}><StatusDot status={selected?.status ?? 'unknown'}/>{selected?.status ?? 'No target'}</div>
|
||||
<button className="icon-button" onClick={() => setDark(!dark)} aria-label="Toggle color theme">{dark ? <Sun/> : <Moon/>}</button>
|
||||
<button className="icon-button" aria-label="Sign out" onClick={() => void api('/auth/logout', {method:'POST'}).finally(() => location.reload())}><LogOut/></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="content">
|
||||
<PageHeader title={page} server={selected}/>
|
||||
<PageContent page={page} server={selected} servers={servers.data ?? []} user={me.data}/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Login({onSuccess}:{onSuccess:()=>void}) {
|
||||
const [error, setError] = useState('')
|
||||
const mutation = useMutation({mutationFn: ({username,password}:{username:string;password:string}) => api<User>('/auth/login', {method:'POST',body:JSON.stringify({username,password})}), onSuccess, onError:(reason) => setError(reason instanceof Error ? reason.message : 'Sign in failed')})
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault(); const values = new FormData(event.currentTarget)
|
||||
mutation.mutate({username:String(values.get('username')),password:String(values.get('password'))})
|
||||
}
|
||||
return <div className="login-page"><div className="login-glow"/><form className="login-card" onSubmit={submit}>
|
||||
<div className="brand login-brand"><div className="brand-mark"><Activity/></div><span>VPS Control</span></div>
|
||||
<p className="eyebrow">CONTROL PLANE ACCESS</p><h1>Infrastructure, in focus.</h1><p>Authenticate to inspect your private server fleet.</p>
|
||||
<label>Username<input name="username" autoComplete="username" required autoFocus/></label>
|
||||
<label>Password<input name="password" type="password" autoComplete="current-password" required/></label>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
<button className="primary" disabled={mutation.isPending}>{mutation.isPending ? 'Authenticating…' : 'Sign in securely'}</button>
|
||||
<span className="login-note"><KeyRound size={14}/> Session protected by HttpOnly cookie + CSRF</span>
|
||||
</form></div>
|
||||
}
|
||||
|
||||
function PageHeader({title,server}:{title:string;server?:Server}) {
|
||||
return <div className="page-header"><div><div className="eyebrow">{server ? `${server.name} · ${server.hostname || 'hostname pending'}` : 'FLEET CONTROL'}</div><h1>{title}</h1></div>
|
||||
{server?.last_seen && <span className="last-seen">Last contact {new Date(server.last_seen).toLocaleString()}</span>}</div>
|
||||
}
|
||||
|
||||
function PageContent({page,server,servers,user}:{page:Page;server?:Server;servers:Server[];user:User}) {
|
||||
if (page === 'Servers') return <ServersPage servers={servers} user={user}/>
|
||||
if (page === 'Audit') return <AuditPage/>
|
||||
if (page === 'Settings') return <SettingsPage user={user} server={server}/>
|
||||
if (!server) return <Empty title="No servers enrolled" detail="Add the first private agent endpoint from Servers."/>
|
||||
switch (page) {
|
||||
case 'Overview': return <Overview server={server}/>
|
||||
case 'Network': return <NetworkPage server={server}/>
|
||||
case 'Firewall': return <FirewallPage server={server}/>
|
||||
case 'Connections': return <ConnectionsPage server={server}/>
|
||||
case 'Security': return <SecurityPage server={server}/>
|
||||
case 'Fail2Ban': return <Fail2BanPage server={server} role={user.role}/>
|
||||
case 'WireGuard': return <WireGuardPage server={server}/>
|
||||
case 'Services': return <ServicesPage server={server} role={user.role}/>
|
||||
case 'Logs': return <LogsPage server={server}/>
|
||||
case 'Rate Limits': return <RateLimitsPage/>
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function useServerData<T>(server:Server, route:string, interval?:number) {
|
||||
return useQuery<T>({queryKey:['server',server.id,route],queryFn:()=>api(`/servers/${server.id}/${route}`),refetchInterval:interval})
|
||||
}
|
||||
|
||||
function Overview({server}:{server:Server}) {
|
||||
const status = useServerData<SystemStatus>(server,'status',10_000)
|
||||
const interfaces = useServerData<Interface[]>(server,'interfaces',10_000)
|
||||
const listeners = useServerData<SocketInfo[]>(server,'listeners',15_000)
|
||||
const connections = useServerData<SocketInfo[]>(server,'connections',15_000)
|
||||
const services = useServerData<Service[]>(server,'services',15_000)
|
||||
const metrics = useServerData<MetricSample[]>(server,'metrics?limit=60',30_000)
|
||||
if (status.isPending) return <PanelLoading/>
|
||||
if (status.isError) return <QueryError error={status.error}/>
|
||||
const root = status.data.filesystems.find((item)=>item.mountpoint==='/') ?? status.data.filesystems[0]
|
||||
const memory = percent(status.data.memory_used_bytes,status.data.memory_total_bytes)
|
||||
const disk = root ? percent(root.used_bytes,root.total_bytes) : 0
|
||||
const totals = interfaces.data?.reduce((sum,item)=>({rx:sum.rx+item.rx_bytes,tx:sum.tx+item.tx_bytes}),{rx:0,tx:0})
|
||||
return <div className="stack">
|
||||
{status.data.reboot_required && <div className="warning-banner"><ShieldAlert/>Host reports that a reboot is required.</div>}
|
||||
<div className="metric-grid">
|
||||
<Metric label="CPU USAGE" value={`${status.data.cpu_usage_percent.toFixed(1)}%`} sub={`${status.data.cpu_core_count} cores · load ${status.data.load_average[0].toFixed(2)}`} level={status.data.cpu_usage_percent}/>
|
||||
<Metric label="MEMORY" value={`${memory.toFixed(1)}%`} sub={`${formatBytes(status.data.memory_used_bytes)} / ${formatBytes(status.data.memory_total_bytes)}`} level={memory}/>
|
||||
<Metric label="ROOT DISK" value={root ? `${disk.toFixed(1)}%` : '—'} sub={root ? `${formatBytes(root.used_bytes)} / ${formatBytes(root.total_bytes)}` : 'Unavailable'} level={disk}/>
|
||||
<Metric label="UPTIME" value={formatDuration(status.data.uptime_seconds)} sub={`${status.data.os} · ${status.data.kernel}`}/>
|
||||
</div>
|
||||
<div className="overview-grid"><Card title="CPU / memory history" badge="1 min samples"><MiniChart samples={metrics.data ?? []}/></Card>
|
||||
<Card title="Live surface" badge="Now"><div className="surface-stats"><Stat label="Connections" value={connections.data?.length ?? '—'}/><Stat label="Listeners" value={listeners.data?.length ?? '—'}/><Stat label="Network RX" value={formatBytes(totals?.rx)}/><Stat label="Network TX" value={formatBytes(totals?.tx)}/></div></Card></div>
|
||||
<Card title="Allow-listed services" badge={`${services.data?.filter((item)=>item.active_state==='active').length ?? 0}/${services.data?.length ?? 0} active`}>
|
||||
<div className="service-strip">{services.data?.map((service)=><div key={service.name}><StatusDot status={service.active_state==='active'?'online':'offline'}/><span>{service.name}</span><small>{service.sub_state}</small></div>)}{services.data?.length===0&&<EmptyLine text="No services are allow-listed on the agent."/>}</div>
|
||||
</Card>
|
||||
</div>
|
||||
}
|
||||
|
||||
function NetworkPage({server}:{server:Server}) {
|
||||
const interfaces = useServerData<Interface[]>(server,'interfaces',10_000)
|
||||
const routes = useServerData<unknown>(server,'routes',30_000)
|
||||
return <div className="stack"><DataPanel query={interfaces}><Card title="Interfaces" badge={`${interfaces.data?.length ?? 0} detected`}><Table headers={['State','Interface','Addresses','Received','Transmitted']} rows={(interfaces.data??[]).map((item)=>[<StatusDot status={item.state==='up'?'online':'offline'}/>,<code>{item.name}</code>,item.addresses.join(', ')||'—',formatBytes(item.rx_bytes),formatBytes(item.tx_bytes)])}/></Card></DataPanel>
|
||||
<DataPanel query={routes}><Card title="Kernel routes" badge="Read only"><pre className="code-block">{JSON.stringify(routes.data,null,2)}</pre></Card></DataPanel></div>
|
||||
}
|
||||
|
||||
function FirewallPage({server}:{server:Server}) {
|
||||
const firewall = useServerData<FirewallStatus>(server,'firewall',30_000)
|
||||
return <DataPanel query={firewall}><div className="stack"><div className="notice"><Shield/><div><strong>{firewall.data?.backend ?? 'Unknown'} backend</strong><span>Rules are read-only in v1. Unknown and external rules are never mutated.</span></div></div>
|
||||
<Card title="Managed by VPS Control" badge={firewall.data?.management_enabled?'Enabled':'Disabled'}><EmptyLine text={`Reserved table: ${firewall.data?.managed_table ?? 'inet vps_control'}. Crash-safe privileged helper is required before writes can be enabled.`}/></Card>
|
||||
<Card title="External / unmanaged rules" badge="Read only">{firewall.data?.warning&&<div className="inline-warning">{firewall.data.warning}</div>}<pre className="code-block tall">{JSON.stringify(firewall.data?.ruleset,null,2)}</pre></Card></div></DataPanel>
|
||||
}
|
||||
|
||||
function ConnectionsPage({server}:{server:Server}) {
|
||||
const connections = useServerData<SocketInfo[]>(server,'connections',10_000)
|
||||
const [filter,setFilter] = useState('')
|
||||
const rows=(connections.data??[]).filter((item)=>`${item.protocol} ${item.remote_address} ${item.local_port} ${item.state}`.toLowerCase().includes(filter.toLowerCase()))
|
||||
const states=useMemo(()=>Object.entries(rows.reduce<Record<string,number>>((all,item)=>({...all,[item.state]:(all[item.state]??0)+1}),{})).sort((a,b)=>b[1]-a[1]),[rows])
|
||||
return <DataPanel query={connections}><div className="stack"><div className="summary-row">{states.slice(0,4).map(([name,count])=><Stat key={name} label={name} value={count}/>)}</div><Card title="Current connections" badge={`${rows.length} sockets`} action={<input className="filter" placeholder="Filter protocol, IP, port…" value={filter} onChange={(e)=>setFilter(e.target.value)}/>}><Table headers={['Protocol','Local','Remote','State','Process']} rows={rows.map(item=>[item.protocol.toUpperCase(),<code>{item.local_address}:{item.local_port}</code>,<code>{item.remote_address}:{item.remote_port}</code>,<Pill text={item.state}/>,item.process?`${item.process} · ${item.pid}`:'—'])}/></Card></div></DataPanel>
|
||||
}
|
||||
|
||||
function SecurityPage({server}:{server:Server}) {
|
||||
const status=useServerData<SystemStatus>(server,'status')
|
||||
const listeners=useServerData<SocketInfo[]>(server,'listeners')
|
||||
const publicListeners=(listeners.data??[]).filter((item)=>['0.0.0.0','::'].includes(item.local_address))
|
||||
return <div className="stack"><div className="metric-grid compact"><Metric label="REBOOT REQUIRED" value={status.data?.reboot_required?'YES':'NO'} sub="Reported by the operating system"/><Metric label="PUBLIC BINDS" value={String(publicListeners.length)} sub="Firewall reachability requires review"/><Metric label="AGENT TRANSPORT" value={server.agent_endpoint.startsWith('https')?'TLS':'PRIVATE HTTP'} sub={server.agent_endpoint}/></div><Card title="Factual warnings" badge="No synthetic score"><div className="event-list">{status.data?.reboot_required&&<Event severity="warning" text="A reboot-required marker exists on the host."/>}{publicListeners.map((item)=><Event key={`${item.protocol}-${item.local_port}`} severity="warning" text={`${item.process??'Unknown process'} listens on every address at ${item.protocol.toUpperCase()}/${item.local_port}.`}/>)}{!status.data?.reboot_required&&!publicListeners.length&&<EmptyLine text="No warnings in the current snapshot."/>}</div></Card></div>
|
||||
}
|
||||
|
||||
function Fail2BanPage({server,role}:{server:Server;role:Role}) {
|
||||
const queryClient=useQueryClient(); const status=useServerData<JailStatus>(server,'fail2ban',15_000)
|
||||
const mutation=useMutation({mutationFn:(input:{action:'ban'|'unban';jail:string;ip:string})=>api(`/servers/${server.id}/fail2ban/${input.action}`,{method:'POST',body:JSON.stringify(input)}),onSuccess:()=>void queryClient.invalidateQueries({queryKey:['server',server.id,'fail2ban']})})
|
||||
const submit=(event:FormEvent<HTMLFormElement>)=>{event.preventDefault();const data=new FormData(event.currentTarget);mutation.mutate({action:data.get('action') as 'ban'|'unban',jail:String(data.get('jail')),ip:String(data.get('ip'))})}
|
||||
return <DataPanel query={status}><div className="split"><Card title="Daemon status" badge={status.data?.running?'Running':'Unavailable'}><div className="details"><Stat label="Installed" value={status.data?.installed?'Yes':'No'}/><Stat label="Version" value={status.data?.version??'—'}/><Stat label="Jails" value={status.data?.jails.length??0}/></div><div className="tag-list">{status.data?.jails.map(jail=><span key={jail}>{jail}</span>)}</div></Card><Card title="Manual action" badge="Audited"><form className="action-form" onSubmit={submit}><select name="action"><option value="ban">Ban</option><option value="unban">Unban</option></select><select name="jail" required>{status.data?.jails.map(jail=><option key={jail}>{jail}</option>)}</select><input name="ip" required placeholder="192.0.2.20"/><button className="primary" disabled={role==='viewer'||mutation.isPending}>Apply</button>{role==='viewer'&&<small>Operator role required.</small>}{mutation.error&&<small className="danger">{mutation.error.message}</small>}</form></Card></div></DataPanel>
|
||||
}
|
||||
|
||||
function WireGuardPage({server}:{server:Server}) {
|
||||
const query=useServerData<WireGuardInterface[]>(server,'wireguard',10_000)
|
||||
return <DataPanel query={query}><div className="stack">{query.data?.map(item=><Card key={item.name} title={item.name} badge={`UDP ${item.listen_port}`}><Table headers={['Peer','Endpoint','Allowed IPs','Last handshake','RX / TX']} rows={item.peers.map(peer=>[<code>{peer.public_key.slice(0,12)}…</code>,peer.endpoint??'—',peer.allowed_ips.join(', '),peer.latest_handshake?new Date(peer.latest_handshake*1000).toLocaleString():'Never',`${formatBytes(peer.rx_bytes)} / ${formatBytes(peer.tx_bytes)}`])}/></Card>)}{query.data?.length===0&&<Empty title="WireGuard unavailable" detail="No readable WireGuard interfaces were reported."/>}</div></DataPanel>
|
||||
}
|
||||
|
||||
function ServicesPage({server,role}:{server:Server;role:Role}) {
|
||||
const queryClient=useQueryClient(); const query=useServerData<Service[]>(server,'services',15_000)
|
||||
const mutation=useMutation({mutationFn:(name:string)=>api(`/servers/${server.id}/services/${encodeURIComponent(name)}/restart`,{method:'POST',body:'{}'}),onSuccess:()=>void queryClient.invalidateQueries({queryKey:['server',server.id,'services']})})
|
||||
return <DataPanel query={query}><Card title="Allow-listed systemd services" badge={`${query.data?.length??0} configured`}><Table headers={['State','Unit','Sub-state','PID','Memory','Action']} rows={(query.data??[]).map(item=>[<StatusDot status={item.active_state==='active'?'online':'offline'}/>,<code>{item.name}</code>,item.sub_state,item.pid||'—',formatBytes(item.memory_bytes),<button className="table-action" disabled={role==='viewer'||mutation.isPending} onClick={()=>mutation.mutate(item.name)}><ListRestart/>Restart</button>])}/></Card></DataPanel>
|
||||
}
|
||||
|
||||
function ServersPage({servers,user}:{servers:Server[];user:User}) {
|
||||
const queryClient=useQueryClient(); const [open,setOpen]=useState(false)
|
||||
const mutation=useMutation({mutationFn:(body:unknown)=>api('/servers',{method:'POST',body:JSON.stringify(body)}),onSuccess:()=>{setOpen(false);void queryClient.invalidateQueries({queryKey:['servers']})}})
|
||||
const submit=(event:FormEvent<HTMLFormElement>)=>{event.preventDefault();const data=new FormData(event.currentTarget);mutation.mutate({name:data.get('name'),hostname:data.get('hostname'),agent_endpoint:data.get('endpoint'),credential_ref:data.get('credential'),tags:String(data.get('tags')??'').split(',').map(v=>v.trim()).filter(Boolean)})}
|
||||
return <div className="stack"><div className="toolbar"><span>{servers.length} enrolled nodes</span>{user.role==='administrator'&&<button className="primary small" onClick={()=>setOpen(!open)}><Plus/>Add server</button>}</div>{open&&<Card title="Enroll server" badge="Private endpoint only"><form className="server-form" onSubmit={submit}><label>Name<input name="name" required/></label><label>Hostname<input name="hostname"/></label><label>Agent endpoint<input name="endpoint" required placeholder="http://10.10.0.2:9105"/></label><label>Credential file reference<input name="credential" required placeholder="agent_token"/></label><label>Tags<input name="tags" placeholder="production, eu-central"/></label><button className="primary" disabled={mutation.isPending}>Enroll</button>{mutation.error&&<small className="danger">{mutation.error.message}</small>}</form></Card>}
|
||||
<div className="server-grid">{servers.map(server=><Card key={server.id} title={server.name} badge={server.status}><div className="server-card"><div className="server-icon"><ServerIcon/></div><div><strong>{server.hostname||'Awaiting first contact'}</strong><code>{server.agent_endpoint}</code><span>{server.last_seen?`Seen ${new Date(server.last_seen).toLocaleString()}`:'Never contacted'}</span></div></div><div className="tag-list">{server.tags.map(tag=><span key={tag}>{tag}</span>)}</div></Card>)}{!servers.length&&<Empty title="No servers enrolled" detail="Mount an agent token secret, then add its private endpoint."/>}</div></div>
|
||||
}
|
||||
|
||||
function AuditPage() {
|
||||
const query=useQuery<AuditEvent[]>({queryKey:['audit'],queryFn:()=>api('/audit?limit=200'),refetchInterval:15_000})
|
||||
return <DataPanel query={query}><Card title="Control-plane audit trail" badge="Latest 200"><Table headers={['Time','Actor','Action','Target','Result','Source IP']} rows={(query.data??[]).map(item=>[new Date(item.created_at).toLocaleString(),item.username,<code>{item.action}</code>,item.target,<Pill text={item.result}/>,<code>{item.source_ip}</code>])}/></Card></DataPanel>
|
||||
}
|
||||
|
||||
function LogsPage({server}:{server:Server}) {
|
||||
const query=useServerData<AgentLogEntry[]>(server,'logs?limit=500',15_000)
|
||||
const priority=['emerg','alert','crit','err','warning','notice','info','debug']
|
||||
return <DataPanel query={query}><div className="stack">
|
||||
<div className="notice"><FileClock/><div><strong>Backend-retained journald entries</strong><span>The control plane polls only agent-configured systemd units and applies bounded retention. This browser never contacts the VPS agent directly.</span></div></div>
|
||||
<Card title="Agent logs" badge={`${query.data?.length??0} / 500`}><Table headers={['Time','Unit','Priority','PID','Message']} rows={(query.data??[]).map(item=>[
|
||||
new Date(item.timestamp).toLocaleString(),<code>{item.unit}</code>,<Pill text={priority[item.priority]??String(item.priority)}/>,item.pid??'—',<span className="log-message">{item.message}</span>,
|
||||
])}/></Card>
|
||||
</div></DataPanel>
|
||||
}
|
||||
function RateLimitsPage(){return <div className="notice"><Gauge/><div><strong>Policy engine is not enabled in v1</strong><span>L4 nftables policies require the crash-safe privileged helper. L7 provider configuration is planned separately; arbitrary nginx edits will not be supported.</span></div></div>}
|
||||
function SettingsPage({user,server}:{user:User;server?:Server}){return <div className="split"><Card title="Current account" badge={user.role}><div className="details"><Stat label="Username" value={user.username}/><Stat label="Role" value={user.role}/><Stat label="Session" value="HttpOnly + CSRF"/></div></Card><Card title="Agent target" badge={server?.status??'none'}><div className="details"><Stat label="Name" value={server?.name??'—'}/><Stat label="Endpoint" value={server?.agent_endpoint??'—'}/><Stat label="Last contact" value={server?.last_seen?new Date(server.last_seen).toLocaleString():'—'}/></div></Card></div>}
|
||||
|
||||
function Metric({label,value,sub,level}:{label:string;value:string;sub:string;level?:number}){return <div className="metric"><span className="eyebrow">{label}</span><strong>{value}</strong><small>{sub}</small>{level!==undefined&&<div className="meter"><i style={{width:`${Math.min(100,Math.max(0,level))}%`}}/></div>}</div>}
|
||||
function Card({title,badge,action,children}:{title:string;badge?:string;action?:ReactNode;children:ReactNode}){return <section className="card"><header><div><h2>{title}</h2>{badge&&<span className="badge">{badge}</span>}</div>{action}</header><div className="card-body">{children}</div></section>}
|
||||
function Stat({label,value}:{label:string;value:ReactNode}){return <div className="stat"><span>{label}</span><strong>{value}</strong></div>}
|
||||
function StatusDot({status}:{status:string}){return <i className={`status-dot ${status}`}/>}
|
||||
function Pill({text}:{text:string}){return <span className={`pill ${text.toLowerCase()}`}>{text}</span>}
|
||||
function Empty({title,detail}:{title:string;detail:string}){return <div className="empty"><Unplug/><h2>{title}</h2><p>{detail}</p></div>}
|
||||
function EmptyLine({text}:{text:string}){return <div className="empty-line">{text}</div>}
|
||||
function Event({severity,text}:{severity:string;text:string}){return <div className={`event ${severity}`}><StatusDot status={severity}/><span>{text}</span></div>}
|
||||
function LoadingScreen(){return <div className="loading-screen"><div className="brand-mark pulse"><Activity/></div><span>Establishing control plane…</span></div>}
|
||||
function PanelLoading(){return <div className="panel-loading"><i/><i/><i/></div>}
|
||||
function QueryError({error}:{error:Error}){return <div className="error-state"><ShieldAlert/><h2>Data unavailable</h2><p>{error instanceof APIError&&error.status===502?'The agent could not be reached over its private endpoint.':error.message}</p></div>}
|
||||
function DataPanel({query,children}:{query:{isPending:boolean;isError:boolean;error:Error|null};children:ReactNode}){if(query.isPending)return <PanelLoading/>;if(query.isError)return <QueryError error={query.error??new Error('Request failed')}/>;return <>{children}</>}
|
||||
function Table({headers,rows}:{headers:string[];rows:ReactNode[][]}){return <div className="table-wrap"><table><thead><tr>{headers.map(header=><th key={header}>{header}</th>)}</tr></thead><tbody>{rows.map((row,index)=><tr key={index}>{row.map((cell,cellIndex)=><td key={cellIndex}>{cell}</td>)}</tr>)}{!rows.length&&<tr><td colSpan={headers.length}><EmptyLine text="No data in the current snapshot."/></td></tr>}</tbody></table></div>}
|
||||
|
||||
function MiniChart({samples}:{samples:MetricSample[]}) {
|
||||
const ordered=[...samples].reverse(); const points=(key:'cpu'|'memory')=>ordered.map((sample,index)=>`${ordered.length<2?0:index/(ordered.length-1)*100},${40-Math.min(100,sample[key])/2.5}`).join(' ')
|
||||
return <div className="chart"><div className="chart-legend"><span><i className="cpu"/>CPU</span><span><i className="memory"/>Memory</span></div><svg viewBox="0 0 100 42" preserveAspectRatio="none" aria-label="CPU and memory history"><line x1="0" y1="20" x2="100" y2="20"/><line x1="0" y1="40" x2="100" y2="40"/>{ordered.length>1&&<><polyline className="cpu-line" points={points('cpu')}/><polyline className="memory-line" points={points('memory')}/></>}</svg></div>
|
||||
}
|
||||
|
||||
const percent=(used:number,total:number)=>total?used*100/total:0
|
||||
37
control-plane/frontend/src/api.ts
Normal file
37
control-plane/frontend/src/api.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
const csrfCookie = () => document.cookie.split('; ').find((item) => item.startsWith('vps_csrf='))?.split('=')[1] ?? ''
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(public status: number, message: string) { super(message) }
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const method = (init.method ?? 'GET').toUpperCase()
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
if (init.body) headers.set('Content-Type', 'application/json')
|
||||
if (!['GET', 'HEAD'].includes(method)) headers.set('X-CSRF-Token', decodeURIComponent(csrfCookie()))
|
||||
const response = await fetch(`/api/v1${path}`, { ...init, headers, credentials: 'same-origin' })
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({ error: `Request failed (${response.status})` })) as {error?:string}
|
||||
throw new APIError(response.status, data.error ?? 'Request failed')
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const formatBytes = (value?: number) => {
|
||||
if (value === undefined || !Number.isFinite(value)) return '—'
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
let amount = value
|
||||
let index = 0
|
||||
while (Math.abs(amount) >= 1024 && index < units.length - 1) { amount /= 1024; index += 1 }
|
||||
return `${amount.toFixed(index === 0 ? 0 : 1)} ${units[index]}`
|
||||
}
|
||||
|
||||
export const formatDuration = (seconds?: number) => {
|
||||
if (seconds === undefined) return '—'
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
return days > 0 ? `${days}d ${hours}h` : `${hours}h ${Math.floor((seconds % 3600) / 60)}m`
|
||||
}
|
||||
|
||||
12
control-plane/frontend/src/main.tsx
Normal file
12
control-plane/frontend/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000, retry: 1, refetchOnWindowFocus: false } } })
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode><QueryClientProvider client={queryClient}><App /></QueryClientProvider></React.StrictMode>
|
||||
)
|
||||
|
||||
7
control-plane/frontend/src/styles.css
Normal file
7
control-plane/frontend/src/styles.css
Normal file
File diff suppressed because one or more lines are too long
2
control-plane/frontend/src/test-setup.ts
Normal file
2
control-plane/frontend/src/test-setup.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
22
control-plane/frontend/src/types.ts
Normal file
22
control-plane/frontend/src/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type Role = 'viewer' | 'operator' | 'administrator'
|
||||
|
||||
export interface User { id: string; username: string; role: Role }
|
||||
export interface Server {
|
||||
id: string; name: string; hostname: string; agent_endpoint: string; tags: string[]
|
||||
status: 'online' | 'offline' | 'unknown'; last_seen: string | null; created_at: string
|
||||
}
|
||||
export interface Filesystem { mountpoint: string; filesystem: string; total_bytes: number; used_bytes: number; inodes_total: number; inodes_used: number }
|
||||
export interface SystemStatus {
|
||||
hostname: string; os: string; kernel: string; uptime_seconds: number; load_average: [number, number, number]
|
||||
cpu_usage_percent: number; cpu_core_count: number; memory_total_bytes: number; memory_used_bytes: number
|
||||
swap_total_bytes: number; swap_used_bytes: number; filesystems: Filesystem[]; reboot_required: boolean
|
||||
}
|
||||
export interface Interface { name: string; state: string; addresses: string[]; rx_bytes: number; tx_bytes: number }
|
||||
export interface SocketInfo { protocol: string; local_address: string; local_port: number; remote_address: string; remote_port: number; state: string; pid?: number; process?: string }
|
||||
export interface Service { name: string; active_state: string; sub_state: string; pid: number; memory_bytes?: number }
|
||||
export interface JailStatus { installed: boolean; running: boolean; version?: string; jails: string[] }
|
||||
export interface WireGuardInterface { name: string; public_key: string; listen_port: number; peers: Array<{public_key:string;endpoint?:string;allowed_ips:string[];latest_handshake:number;rx_bytes:number;tx_bytes:number}> }
|
||||
export interface FirewallStatus { backend: string; management_enabled: boolean; managed_table: string; ruleset: unknown; warning?: string }
|
||||
export interface AuditEvent { id: number; username: string; server_id?: string; action: string; target: string; result: string; source_ip: string; created_at: string }
|
||||
export interface MetricSample { sampled_at:string; cpu:number; memory:number; disk:number; rx:number; tx:number; connections:number; bans:number }
|
||||
export interface AgentLogEntry { server_id:string; timestamp:string; unit:string; priority:number; message:string; boot_id?:string; pid?:number }
|
||||
Reference in New Issue
Block a user