Initial commit: Rampart v0.2.0
Multi-layer DDoS protection for Minecraft servers. - rampart-core: Edge node with XDP/eBPF + Rust L7 filtering - rampart-manager: REST API with JWT auth, Redis sync - rampart-cli: CLI tool for operators - velocity-plugin: Domain check, HMAC verify, server registry, load balancer - paper-plugin: Auto-registration, heartbeat, HMAC verify - dashboard: React + Vite web UI for management
This commit is contained in:
commit
cf9608ce5d
159 changed files with 15341 additions and 0 deletions
378
dashboard/src/App.css
Normal file
378
dashboard/src/App.css
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--bg-card: #1f2b47;
|
||||
--bg-sidebar: #0f3460;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0b0;
|
||||
--accent: #e94560;
|
||||
--accent-hover: #ff6b81;
|
||||
--green: #2ecc71;
|
||||
--red: #e74c3c;
|
||||
--border: #2a3a5c;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Login */
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--bg-card);
|
||||
padding: 2.5rem;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--accent);
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.login-card input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.login-card button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.login-card button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.login-card button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: var(--red);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--bg-sidebar);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar h2 {
|
||||
color: var(--accent);
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar nav button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar nav button:hover {
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar nav button.active {
|
||||
background: rgba(233, 69, 96, 0.2);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sidebar .logout-btn {
|
||||
margin-top: auto;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar .logout-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main-content h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container {
|
||||
background: var(--bg-card);
|
||||
border-radius: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
tbody tr:nth-child(even) {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 6px rgba(46, 204, 113, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 6px rgba(231, 76, 60, 0.5);
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.spinner::after {
|
||||
content: '';
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.error-msg {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
color: var(--red);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Success message */
|
||||
.success-msg {
|
||||
background: rgba(46, 204, 113, 0.1);
|
||||
color: var(--green);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Blacklist form */
|
||||
.blacklist-form {
|
||||
background: var(--bg-card);
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.blacklist-form input,
|
||||
.blacklist-form select {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
min-width: 140px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.blacklist-form input:focus,
|
||||
.blacklist-form select:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.blacklist-form button {
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.blacklist-form button:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.blacklist-form button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
.sidebar nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sidebar .logout-btn {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
.blacklist-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
.blacklist-form input,
|
||||
.blacklist-form select,
|
||||
.blacklist-form button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
17
dashboard/src/App.tsx
Normal file
17
dashboard/src/App.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useState } from 'react'
|
||||
import Login from './components/Login'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
function App() {
|
||||
const [token, setToken] = useState<string | null>(
|
||||
() => sessionStorage.getItem('rampart_token')
|
||||
)
|
||||
|
||||
if (!token) {
|
||||
return <Login onLogin={(t) => setToken(t)} />
|
||||
}
|
||||
|
||||
return <Layout />
|
||||
}
|
||||
|
||||
export default App
|
||||
122
dashboard/src/api.ts
Normal file
122
dashboard/src/api.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
const BASE = 'http://localhost:8080/api/v1'
|
||||
|
||||
function getToken(): string | null {
|
||||
return sessionStorage.getItem('rampart_token')
|
||||
}
|
||||
|
||||
function setToken(token: string): void {
|
||||
sessionStorage.setItem('rampart_token', token)
|
||||
}
|
||||
|
||||
function clearToken(): void {
|
||||
sessionStorage.removeItem('rampart_token')
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, { ...options, headers })
|
||||
|
||||
if (res.status === 401) {
|
||||
clearToken()
|
||||
window.location.reload()
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || res.statusText)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
name: string
|
||||
server_type: string
|
||||
ip: string
|
||||
port: number
|
||||
status: string
|
||||
online_players: number
|
||||
max_players: number
|
||||
tps: number
|
||||
last_heartbeat: string
|
||||
}
|
||||
|
||||
export interface BlacklistEntry {
|
||||
target: string
|
||||
type: string
|
||||
reason: string
|
||||
created: string
|
||||
expires: string
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: string
|
||||
role: string
|
||||
ip: string
|
||||
status: string
|
||||
last_heartbeat: string
|
||||
}
|
||||
|
||||
export async function login(password: string): Promise<LoginResponse> {
|
||||
const res = await fetch(`${BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || res.statusText)
|
||||
}
|
||||
const data: LoginResponse = await res.json()
|
||||
setToken(data.token)
|
||||
return data
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
clearToken()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
export async function fetchServers(): Promise<Server[]> {
|
||||
return apiFetch<Server[]>('/servers')
|
||||
}
|
||||
|
||||
export async function fetchBlacklist(): Promise<BlacklistEntry[]> {
|
||||
return apiFetch<BlacklistEntry[]>('/blacklist')
|
||||
}
|
||||
|
||||
export async function addBlacklist(
|
||||
target: string,
|
||||
type: string,
|
||||
reason: string,
|
||||
durationSecs: number
|
||||
): Promise<void> {
|
||||
await apiFetch('/blacklist', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target, type, reason, duration_secs: durationSecs }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchNodes(): Promise<Node[]> {
|
||||
return apiFetch<Node[]>('/nodes')
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<HealthResponse> {
|
||||
return apiFetch<HealthResponse>('/health')
|
||||
}
|
||||
137
dashboard/src/components/Blacklist.tsx
Normal file
137
dashboard/src/components/Blacklist.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useState, useEffect, FormEvent } from 'react'
|
||||
import { fetchBlacklist, addBlacklist, type BlacklistEntry } from '../api'
|
||||
|
||||
function Blacklist() {
|
||||
const [entries, setEntries] = useState<BlacklistEntry[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const [target, setTarget] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [duration, setDuration] = useState('3600')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addError, setAddError] = useState('')
|
||||
const [addSuccess, setAddSuccess] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await fetchBlacklist()
|
||||
if (!cancelled) {
|
||||
setEntries(data)
|
||||
setError('')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load blacklist')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
const interval = setInterval(load, 30000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleAdd(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setAddError('')
|
||||
setAddSuccess('')
|
||||
setAdding(true)
|
||||
|
||||
try {
|
||||
await addBlacklist(target, 'ip', reason, parseInt(duration, 10))
|
||||
setAddSuccess(`Added ${target} to blacklist`)
|
||||
setTarget('')
|
||||
setReason('')
|
||||
setDuration('3600')
|
||||
const data = await fetchBlacklist()
|
||||
setEntries(data)
|
||||
} catch (err: unknown) {
|
||||
setAddError(err instanceof Error ? err.message : 'Failed to add entry')
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="spinner" />
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Blacklist</h1>
|
||||
|
||||
<form className="blacklist-form" onSubmit={handleAdd}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="IP Address"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Duration (seconds)"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={adding}>
|
||||
{adding ? 'Adding...' : 'Add to Blacklist'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{addError && <div className="error-msg">{addError}</div>}
|
||||
{addSuccess && <div className="success-msg">{addSuccess}</div>}
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Target</th>
|
||||
<th>Type</th>
|
||||
<th>Reason</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
|
||||
No blacklist entries
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{entries.map((e, i) => (
|
||||
<tr key={i}>
|
||||
<td>{e.target}</td>
|
||||
<td>{e.type}</td>
|
||||
<td>{e.reason}</td>
|
||||
<td>{new Date(e.created).toLocaleString()}</td>
|
||||
<td>{new Date(e.expires).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Blacklist
|
||||
53
dashboard/src/components/Layout.tsx
Normal file
53
dashboard/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useState } from 'react'
|
||||
import { logout } from '../api'
|
||||
import Servers from './Servers'
|
||||
import Blacklist from './Blacklist'
|
||||
import Nodes from './Nodes'
|
||||
|
||||
type Page = 'servers' | 'blacklist' | 'nodes'
|
||||
|
||||
const navItems: { key: Page; label: string }[] = [
|
||||
{ key: 'servers', label: 'Servers' },
|
||||
{ key: 'blacklist', label: 'Blacklist' },
|
||||
{ key: 'nodes', label: 'Nodes' },
|
||||
]
|
||||
|
||||
function Layout() {
|
||||
const [page, setPage] = useState<Page>('servers')
|
||||
|
||||
function renderPage() {
|
||||
switch (page) {
|
||||
case 'servers':
|
||||
return <Servers />
|
||||
case 'blacklist':
|
||||
return <Blacklist />
|
||||
case 'nodes':
|
||||
return <Nodes />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<aside className="sidebar">
|
||||
<h2>Rampart Manager</h2>
|
||||
<nav>
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={page === item.key ? 'active' : ''}
|
||||
onClick={() => setPage(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button className="logout-btn" onClick={logout}>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
<main className="main-content">{renderPage()}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
48
dashboard/src/components/Login.tsx
Normal file
48
dashboard/src/components/Login.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { useState, FormEvent } from 'react'
|
||||
import { login } from '../api'
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: (token: string) => void
|
||||
}
|
||||
|
||||
function Login({ onLogin }: LoginProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await login(password)
|
||||
onLogin(res.token)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<form className="login-card" onSubmit={handleSubmit}>
|
||||
<h1>Rampart</h1>
|
||||
<p>Manager Dashboard</p>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" disabled={loading || !password}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
82
dashboard/src/components/Nodes.tsx
Normal file
82
dashboard/src/components/Nodes.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { fetchNodes, type Node } from '../api'
|
||||
|
||||
function Nodes() {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await fetchNodes()
|
||||
if (!cancelled) {
|
||||
setNodes(data)
|
||||
setError('')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load nodes')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
const interval = setInterval(load, 15000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="spinner" />
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Edge Nodes</h1>
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Role</th>
|
||||
<th>IP</th>
|
||||
<th>Status</th>
|
||||
<th>Last Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
|
||||
No nodes found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{nodes.map((n) => (
|
||||
<tr key={n.id}>
|
||||
<td>{n.id}</td>
|
||||
<td>{n.role}</td>
|
||||
<td>{n.ip}</td>
|
||||
<td>
|
||||
<span className="status-badge">
|
||||
<span className={`status-dot ${n.status === 'online' ? 'online' : 'offline'}`} />
|
||||
{n.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{new Date(n.last_heartbeat).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Nodes
|
||||
86
dashboard/src/components/Servers.tsx
Normal file
86
dashboard/src/components/Servers.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { fetchServers, type Server } from '../api'
|
||||
|
||||
function Servers() {
|
||||
const [servers, setServers] = useState<Server[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await fetchServers()
|
||||
if (!cancelled) {
|
||||
setServers(data)
|
||||
setError('')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load servers')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
const interval = setInterval(load, 10000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="spinner" />
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Servers</h1>
|
||||
{error && <div className="error-msg">{error}</div>}
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>IP:Port</th>
|
||||
<th>Status</th>
|
||||
<th>Online/Max</th>
|
||||
<th>TPS</th>
|
||||
<th>Last Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
|
||||
No servers found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{servers.map((s) => (
|
||||
<tr key={s.name}>
|
||||
<td>{s.name}</td>
|
||||
<td>{s.server_type}</td>
|
||||
<td>{s.ip}:{s.port}</td>
|
||||
<td>
|
||||
<span className="status-badge">
|
||||
<span className={`status-dot ${s.status === 'online' ? 'online' : 'offline'}`} />
|
||||
{s.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{s.online_players}/{s.max_players}</td>
|
||||
<td>{s.tps.toFixed(1)}</td>
|
||||
<td>{new Date(s.last_heartbeat).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Servers
|
||||
10
dashboard/src/main.tsx
Normal file
10
dashboard/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './App.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
1
dashboard/src/vite-env.d.ts
vendored
Normal file
1
dashboard/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
Loading…
Add table
Add a link
Reference in a new issue