feat: rename WHM→NixServer, cPanel→NixClient, bump v0.2.0
- Rename frontend workspaces: whm/ → nixserver/, cpanel/ → nixclient/ - Rename server route dirs: routes/whm/ → routes/nixserver/, routes/cpanel/ → routes/nixclient/ - Update all API prefixes: /api/whm → /api/nixserver, /api/cpanel → /api/nixclient - Rename env vars: WHM_PORT → NIXSERVER_PORT, CPANEL_PORT → NIXCLIENT_PORT - Update config properties: whmPort → nixserverPort, cpanelPort → nixclientPort - Update all UI labels, login pages, sidebar branding, and error messages - Update installer and update scripts to match new names - Add cpanel email route (missing from initial scaffold) - Add .gitattributes to enforce LF line endings - Bump version to 0.2.0 across all package.json files
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus, Search, MoreVertical, UserX, UserCheck, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Account {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
status: string
|
||||
diskUsedMb: number
|
||||
bandwidthUsedMb: number
|
||||
ipAddress: string | null
|
||||
createdAt: string
|
||||
package?: { name: string; diskMb: number }
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
items: Account[]
|
||||
total: number
|
||||
page: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
suspended: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
terminated: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const [data, setData] = useState<PageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionMenu, setActionMenu] = useState<number | null>(null)
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: '25' })
|
||||
if (search) params.set('search', search)
|
||||
if (status) params.set('status', status)
|
||||
const r = await api.get(`/nixserver/accounts?${params}`)
|
||||
setData(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, status])
|
||||
|
||||
useEffect(() => { fetch() }, [fetch])
|
||||
|
||||
const action = async (id: number, act: 'suspend' | 'unsuspend' | 'terminate') => {
|
||||
setActionMenu(null)
|
||||
if (act === 'terminate' && !confirm('Terminate this account? This cannot be undone.')) return
|
||||
try {
|
||||
if (act === 'suspend') await api.post(`/nixserver/accounts/${id}/suspend`)
|
||||
else if (act === 'unsuspend') await api.post(`/nixserver/accounts/${id}/unsuspend`)
|
||||
else await api.delete(`/nixserver/accounts/${id}`)
|
||||
fetch()
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Accounts</h1>
|
||||
<p className="text-[#64748b] text-sm">{data?.total ?? 0} total</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/accounts/create"
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus size={16} /> Create Account
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||
placeholder="Search username, domain, email..."
|
||||
className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => { setStatus(e.target.value); setPage(1) }}
|
||||
className="bg-[#1a1d27] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#2a2d3e]">
|
||||
{['Username', 'Domain', 'Package', 'Status', 'IP', 'Created', ''].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && !data && (
|
||||
<tr><td colSpan={7} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{data?.items.map(acc => (
|
||||
<tr key={acc.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/accounts/${acc.id}`} className="text-indigo-400 hover:text-indigo-300 font-medium">
|
||||
{acc.username}
|
||||
</Link>
|
||||
<p className="text-[#64748b] text-xs">{acc.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.package?.name ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[acc.status] ?? ''}`}>
|
||||
{acc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] font-mono text-xs">{acc.ipAddress ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(acc.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-3 relative">
|
||||
<button
|
||||
onClick={() => setActionMenu(actionMenu === acc.id ? null : acc.id)}
|
||||
className="p-1 text-[#64748b] hover:text-white rounded transition-colors"
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
{actionMenu === acc.id && (
|
||||
<div className="absolute right-4 top-8 z-10 bg-[#1e2130] border border-[#2a2d3e] rounded-lg shadow-lg py-1 w-36">
|
||||
{acc.status === 'active' ? (
|
||||
<button onClick={() => action(acc.id, 'suspend')} className="flex items-center gap-2 w-full px-3 py-2 text-amber-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<UserX size={14} /> Suspend
|
||||
</button>
|
||||
) : acc.status === 'suspended' ? (
|
||||
<button onClick={() => action(acc.id, 'unsuspend')} className="flex items-center gap-2 w-full px-3 py-2 text-emerald-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<UserCheck size={14} /> Unsuspend
|
||||
</button>
|
||||
) : null}
|
||||
<button onClick={() => action(acc.id, 'terminate')} className="flex items-center gap-2 w-full px-3 py-2 text-red-400 hover:bg-[#2a2d3e] text-xs">
|
||||
<Trash2 size={14} /> Terminate
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center text-[#64748b] py-8">No accounts found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
{data && data.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[#2a2d3e]">
|
||||
<span className="text-[#64748b] text-xs">
|
||||
Page {data.page} of {data.totalPages} ({data.total} total)
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= data.totalPages}
|
||||
className="px-3 py-1 text-xs bg-[#1e2130] border border-[#2a2d3e] rounded text-[#94a3b8] disabled:opacity-40 hover:border-indigo-500 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { HardDrive, Download, Trash2, RefreshCw, Play } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Backup { path: string; filename: string; size: number; createdAt: string }
|
||||
|
||||
const fmtSize = (b: number) => b > 1024 * 1024 * 1024 ? `${(b / 1024 / 1024 / 1024).toFixed(1)} GB` : `${(b / 1024 / 1024).toFixed(0)} MB`
|
||||
|
||||
export default function Backup() {
|
||||
const [backups, setBackups] = useState<Backup[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [backing, setBacking] = useState(false)
|
||||
const [accounts, setAccounts] = useState<{ id: number; username: string }[]>([])
|
||||
const [selectedAccount, setSelectedAccount] = useState('')
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [b, a] = await Promise.all([api.get('/nixserver/backup/list'), api.get('/nixserver/accounts?limit=100')])
|
||||
setBackups(b.data.data)
|
||||
setAccounts(a.data.data.items)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const backupAccount = async () => {
|
||||
if (!selectedAccount) return alert('Select an account')
|
||||
setBacking(true)
|
||||
try { await api.post(`/nixserver/backup/account/${selectedAccount}`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Backup failed') }
|
||||
finally { setBacking(false) }
|
||||
}
|
||||
|
||||
const backupServer = async () => {
|
||||
if (!confirm('Start a full server backup? This may take a while.')) return
|
||||
setBacking(true)
|
||||
try { await api.post('/nixserver/backup/server'); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Backup failed') }
|
||||
finally { setBacking(false) }
|
||||
}
|
||||
|
||||
const del = async (path: string) => {
|
||||
if (!confirm('Delete this backup?')) return
|
||||
try { await api.delete('/nixserver/backup', { data: { path } }); fetch() }
|
||||
catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Backup</h1><p className="text-[#64748b] text-sm">{backups.length} backup files</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-3">Account Backup</h2>
|
||||
<div className="flex gap-2">
|
||||
<select value={selectedAccount} onChange={e => setSelectedAccount(e.target.value)} className="flex-1 bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
|
||||
<option value="">Select account...</option>
|
||||
{accounts.map(a => <option key={a.id} value={a.id}>{a.username}</option>)}
|
||||
</select>
|
||||
<button onClick={backupAccount} disabled={backing} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-3 py-2 rounded-lg">
|
||||
<Play size={14} /> {backing ? 'Backing up...' : 'Backup'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-3">Full Server Backup</h2>
|
||||
<p className="text-[#64748b] text-xs mb-3">Backs up /etc, /home, and /var/www</p>
|
||||
<button onClick={backupServer} disabled={backing} className="flex items-center gap-2 bg-amber-600/20 hover:bg-amber-600/30 text-amber-400 border border-amber-500/30 text-sm px-3 py-2 rounded-lg disabled:opacity-50">
|
||||
<HardDrive size={14} /> Full Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup list */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Filename', 'Size', 'Created', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{backups.map(b => (
|
||||
<tr key={b.path} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 font-mono text-white text-xs">{b.filename}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{fmtSize(b.size)}</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(b.createdAt).toLocaleString()}</td>
|
||||
<td className="px-4 py-3"><button onClick={() => del(b.path)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && backups.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No backups</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Package { id: number; name: string }
|
||||
|
||||
export default function CreateAccount() {
|
||||
const navigate = useNavigate()
|
||||
const [packages, setPackages] = useState<Package[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({
|
||||
username: '', password: '', email: '', domain: '', packageId: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/nixserver/packages').then(r => setPackages(r.data.data)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }))
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/nixserver/accounts', {
|
||||
...form,
|
||||
packageId: form.packageId ? parseInt(form.packageId, 10) : undefined,
|
||||
})
|
||||
navigate('/accounts')
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed to create account')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<button onClick={() => navigate(-1)} className="flex items-center gap-2 text-[#64748b] hover:text-white text-sm mb-4 transition-colors">
|
||||
<ArrowLeft size={16} /> Back
|
||||
</button>
|
||||
|
||||
<h1 className="text-white text-xl font-semibold mb-1">Create Account</h1>
|
||||
<p className="text-[#64748b] text-sm mb-6">Provision a new hosting account</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 text-sm px-3 py-2 rounded-lg">{error}</div>
|
||||
)}
|
||||
|
||||
{[
|
||||
{ key: 'username', label: 'Username', type: 'text', placeholder: 'john', hint: 'Lowercase letters, numbers, hyphens' },
|
||||
{ key: 'domain', label: 'Primary Domain', type: 'text', placeholder: 'example.com' },
|
||||
{ key: 'email', label: 'Contact Email', type: 'email', placeholder: '[email protected]' },
|
||||
{ key: 'password', label: 'NixClient Password', type: 'password', placeholder: '••••••••', hint: 'Min 8 characters' },
|
||||
].map(({ key, label, type, placeholder, hint }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-[#94a3b8] text-xs font-medium mb-1.5">{label}</label>
|
||||
<input
|
||||
type={type}
|
||||
value={form[key as keyof typeof form]}
|
||||
onChange={set(key as keyof typeof form)}
|
||||
required
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
{hint && <p className="text-[#64748b] text-xs mt-1">{hint}</p>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs font-medium mb-1.5">Package</label>
|
||||
<select
|
||||
value={form.packageId}
|
||||
onChange={set('packageId')}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">No package (unlimited)</option>
|
||||
{packages.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => navigate(-1)} className="flex-1 border border-[#2a2d3e] text-[#94a3b8] text-sm py-2.5 rounded-lg hover:bg-[#1e2130] transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm py-2.5 rounded-lg transition-colors">
|
||||
{loading ? 'Creating...' : 'Create Account'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { RefreshCw, Globe, Plus, Trash2, Pencil } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Zone { id: number; domain: string; status: string; account: { username: string }; _count: { records: number } }
|
||||
interface Record_ { id: number; type: string; name: string; value: string; ttl: number; priority: number | null }
|
||||
|
||||
export default function DNS() {
|
||||
const [zones, setZones] = useState<Zone[]>([])
|
||||
const [selected, setSelected] = useState<Zone | null>(null)
|
||||
const [records, setRecords] = useState<Record_[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ type: 'A', name: '@', value: '', ttl: 3600, priority: '' })
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchZones = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/nixserver/dns'); setZones(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const loadZone = async (zone: Zone) => {
|
||||
setSelected(zone)
|
||||
const r = await api.get(`/nixserver/dns/${zone.id}`)
|
||||
setRecords(r.data.data.records ?? [])
|
||||
}
|
||||
|
||||
useEffect(() => { fetchZones() }, [])
|
||||
|
||||
const addRecord = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); if (!selected) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.post(`/nixserver/dns/${selected.id}/records`, { ...form, priority: form.priority ? parseInt(form.priority, 10) : undefined })
|
||||
setShowForm(false)
|
||||
loadZone(selected)
|
||||
} catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const deleteRecord = async (recordId: number) => {
|
||||
if (!selected || !confirm('Delete this DNS record?')) return
|
||||
try { await api.delete(`/nixserver/dns/${selected.id}/records/${recordId}`); loadZone(selected) }
|
||||
catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">DNS</h1><p className="text-[#64748b] text-sm">{zones.length} zones</p></div>
|
||||
<button onClick={fetchZones} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Zone list */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a2d3e]"><h2 className="text-white text-sm font-medium">Zones</h2></div>
|
||||
<div className="divide-y divide-[#2a2d3e]">
|
||||
{zones.map(z => (
|
||||
<button key={z.id} onClick={() => loadZone(z)} className={`w-full flex items-center gap-3 px-4 py-3 hover:bg-[#1e2130] text-left transition-colors ${selected?.id === z.id ? 'bg-indigo-600/10' : ''}`}>
|
||||
<Globe size={14} className="text-[#64748b] flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm truncate">{z.domain}</p>
|
||||
<p className="text-[#64748b] text-xs">{z.account.username} · {z._count.records} records</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{zones.length === 0 && <div className="text-center text-[#64748b] py-6 text-sm">No zones</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Records */}
|
||||
<div className="lg:col-span-2 bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white text-sm font-medium">{selected.domain}</h2>
|
||||
<button onClick={() => setShowForm(v => !v)} className="flex items-center gap-1.5 text-xs text-indigo-400 hover:text-indigo-300">
|
||||
<Plus size={14} /> Add Record
|
||||
</button>
|
||||
</div>
|
||||
{showForm && (
|
||||
<form onSubmit={addRecord} className="px-4 py-3 border-b border-[#2a2d3e] flex gap-2 flex-wrap">
|
||||
<select value={form.type} onChange={e => setForm(f => ({ ...f, type: e.target.value }))} className="bg-[#0f1117] border border-[#2a2d3e] text-white text-xs rounded px-2 py-1.5 focus:outline-none">
|
||||
{['A','AAAA','CNAME','MX','TXT','NS','SRV','CAA'].map(t => <option key={t}>{t}</option>)}
|
||||
</select>
|
||||
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Name" required className="bg-[#0f1117] border border-[#2a2d3e] text-white text-xs rounded px-2 py-1.5 w-28 focus:outline-none focus:border-indigo-500" />
|
||||
<input value={form.value} onChange={e => setForm(f => ({ ...f, value: e.target.value }))} placeholder="Value" required className="bg-[#0f1117] border border-[#2a2d3e] text-white text-xs rounded px-2 py-1.5 flex-1 min-w-32 focus:outline-none focus:border-indigo-500" />
|
||||
{form.type === 'MX' && <input value={form.priority} onChange={e => setForm(f => ({ ...f, priority: e.target.value }))} placeholder="Priority" type="number" className="bg-[#0f1117] border border-[#2a2d3e] text-white text-xs rounded px-2 py-1.5 w-20 focus:outline-none" />}
|
||||
<button type="submit" disabled={saving} className="bg-indigo-600 hover:bg-indigo-500 text-white text-xs px-3 py-1.5 rounded disabled:opacity-50">{saving ? '...' : 'Add'}</button>
|
||||
</form>
|
||||
)}
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Type','Name','Value','TTL',''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-2">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{records.map(r => (
|
||||
<tr key={r.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-2"><span className="text-xs px-1.5 py-0.5 rounded bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 font-mono">{r.type}</span></td>
|
||||
<td className="px-4 py-2 font-mono text-[#94a3b8]">{r.name}</td>
|
||||
<td className="px-4 py-2 font-mono text-[#64748b] max-w-xs truncate">{r.value}</td>
|
||||
<td className="px-4 py-2 text-[#64748b]">{r.ttl}</td>
|
||||
<td className="px-4 py-2"><button onClick={() => deleteRecord(r.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={12} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{records.length === 0 && <tr><td colSpan={5} className="text-center text-[#64748b] py-6">No records</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-48 text-[#64748b] text-sm">Select a zone to view records</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { Users, Globe, Server, HardDrive, Activity, AlertTriangle } from 'lucide-react'
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface SystemStats {
|
||||
cpu: number
|
||||
memory: { total: number; used: number; free: number; percent: number }
|
||||
disk: { total: number; used: number; free: number; percent: number }
|
||||
load: [number, number, number]
|
||||
uptime: number
|
||||
}
|
||||
|
||||
interface AccountStats {
|
||||
total: number
|
||||
active: number
|
||||
suspended: number
|
||||
terminated: number
|
||||
}
|
||||
|
||||
interface StatCard {
|
||||
label: string
|
||||
value: string | number
|
||||
sub?: string
|
||||
icon: React.ComponentType<{ size?: number; className?: string }>
|
||||
color: string
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
const gb = bytes / 1024 / 1024 / 1024
|
||||
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1024 / 1024).toFixed(0)} MB`
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<SystemStats | null>(null)
|
||||
const [accountStats, setAccountStats] = useState<AccountStats | null>(null)
|
||||
const [cpuHistory, setCpuHistory] = useState<{ t: string; cpu: number; mem: number }[]>([])
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/nixserver/accounts/stats').then(r => setAccountStats(r.data.data)).catch(() => {})
|
||||
api.get('/nixserver/system/stats').then(r => setStats(r.data.data)).catch(() => {})
|
||||
|
||||
// WebSocket live stats
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
const ws = new WebSocket(`ws://${window.location.hostname}:2087/ws/stats?token=${token}`)
|
||||
wsRef.current = ws
|
||||
ws.onmessage = e => {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'stats') {
|
||||
setStats(msg.data)
|
||||
setCpuHistory(prev => [
|
||||
...prev.slice(-29),
|
||||
{ t: new Date().toLocaleTimeString(), cpu: msg.data.cpu, mem: msg.data.memory.percent },
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => wsRef.current?.close()
|
||||
}, [])
|
||||
|
||||
const cards: StatCard[] = [
|
||||
{
|
||||
label: 'Total Accounts',
|
||||
value: accountStats?.total ?? '—',
|
||||
sub: `${accountStats?.active ?? 0} active`,
|
||||
icon: Users,
|
||||
color: 'text-indigo-400',
|
||||
},
|
||||
{
|
||||
label: 'CPU Usage',
|
||||
value: stats ? `${stats.cpu}%` : '—',
|
||||
sub: `Load: ${stats?.load[0].toFixed(2) ?? '—'}`,
|
||||
icon: Activity,
|
||||
color: 'text-emerald-400',
|
||||
},
|
||||
{
|
||||
label: 'Memory',
|
||||
value: stats ? `${stats.memory.percent}%` : '—',
|
||||
sub: stats ? `${formatBytes(stats.memory.used)} / ${formatBytes(stats.memory.total)}` : '—',
|
||||
icon: Server,
|
||||
color: 'text-blue-400',
|
||||
},
|
||||
{
|
||||
label: 'Disk',
|
||||
value: stats ? `${stats.disk.percent}%` : '—',
|
||||
sub: stats ? `${formatBytes(stats.disk.used)} / ${formatBytes(stats.disk.total)}` : '—',
|
||||
icon: HardDrive,
|
||||
color: 'text-amber-400',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Dashboard</h1>
|
||||
<p className="text-[#64748b] text-sm mt-0.5">Server overview and real-time metrics</p>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{cards.map(card => (
|
||||
<div key={card.label} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-[#64748b] text-xs font-medium">{card.label}</span>
|
||||
<card.icon size={16} className={card.color} />
|
||||
</div>
|
||||
<p className="text-white text-2xl font-bold">{card.value}</p>
|
||||
{card.sub && <p className="text-[#64748b] text-xs mt-1">{card.sub}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CPU / Memory chart */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-4">CPU & Memory — Live</h2>
|
||||
{cpuHistory.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<AreaChart data={cpuHistory}>
|
||||
<defs>
|
||||
<linearGradient id="cpu" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#6366f1" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#6366f1" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="mem" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="t" tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} />
|
||||
<YAxis domain={[0, 100]} tick={{ fill: '#64748b', fontSize: 10 }} tickLine={false} axisLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1e2130', border: '1px solid #2a2d3e', borderRadius: 8, fontSize: 12 }}
|
||||
labelStyle={{ color: '#94a3b8' }}
|
||||
/>
|
||||
<Area type="monotone" dataKey="cpu" stroke="#6366f1" fill="url(#cpu)" strokeWidth={2} name="CPU %" />
|
||||
<Area type="monotone" dataKey="mem" stroke="#10b981" fill="url(#mem)" strokeWidth={2} name="Mem %" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-40 flex items-center justify-center text-[#64748b] text-sm">
|
||||
Connecting to live stats...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account status breakdown */}
|
||||
{accountStats && (
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-4">Account Status</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: 'Active', value: accountStats.active, color: 'text-emerald-400' },
|
||||
{ label: 'Suspended', value: accountStats.suspended, color: 'text-amber-400' },
|
||||
{ label: 'Terminated', value: accountStats.terminated, color: 'text-red-400' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="text-center">
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
<p className="text-[#64748b] text-xs mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Mail, Plus, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface EmailAccount { id: number; address: string; domain: string; quotaMb: number; usedMb: number; status: string }
|
||||
|
||||
export default function Email() {
|
||||
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [hosting, setHosting] = useState<{ id: number; username: string; domain: string }[]>([])
|
||||
const [form, setForm] = useState({ accountId: '', username: '', password: '', quotaMb: 500 })
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [em, acc] = await Promise.all([api.get('/nixserver/email'), api.get('/nixserver/accounts?limit=100')])
|
||||
setAccounts(em.data.data)
|
||||
setHosting(acc.data.data.items)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try {
|
||||
await api.post('/nixserver/email', { ...form, accountId: parseInt(form.accountId, 10), quotaMb: form.quotaMb })
|
||||
setShowModal(false); fetch()
|
||||
} catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const del = async (id: number, address: string) => {
|
||||
if (!confirm(`Delete ${address}?`)) return
|
||||
try { await api.delete(`/nixserver/email/${id}`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Email Accounts</h1><p className="text-[#64748b] text-sm">{accounts.length} accounts</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Email</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Address', 'Quota', 'Used', 'Status', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{accounts.map(a => (
|
||||
<tr key={a.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 text-white">{a.address}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{a.quotaMb} MB</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{a.usedMb} MB</td>
|
||||
<td className="px-4 py-3"><span className={`text-xs px-2 py-0.5 rounded-full border ${a.status === 'active' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-red-500/10 text-red-400 border-red-500/20'}`}>{a.status}</span></td>
|
||||
<td className="px-4 py-3"><button onClick={() => del(a.id, a.address)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && accounts.length === 0 && <tr><td colSpan={5} className="text-center text-[#64748b] py-8">No email accounts</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white font-medium">New Email Account</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={save} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Account</label>
|
||||
<select value={form.accountId} onChange={e => setForm(f => ({ ...f, accountId: e.target.value }))} required className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
|
||||
<option value="">Select account...</option>
|
||||
{hosting.map(h => <option key={h.id} value={h.id}>{h.username} ({h.domain})</option>)}
|
||||
</select></div>
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Username</label>
|
||||
<input value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))} required placeholder="info" className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Password</label>
|
||||
<input type="password" value={form.password} onChange={e => setForm(f => ({ ...f, password: e.target.value }))} required className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#2a2d3e] text-[#94a3b8] text-sm py-2 rounded hover:bg-[#1e2130]">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Creating...' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { Shield } from 'lucide-react'
|
||||
|
||||
export default function Login() {
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/')
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Login failed'
|
||||
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f1117] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-indigo-600 flex items-center justify-center mb-3">
|
||||
<Shield size={24} className="text-white" />
|
||||
</div>
|
||||
<h1 className="text-white text-xl font-bold">NixServer</h1>
|
||||
<p className="text-[#64748b] text-sm mt-1">Web Host Manager</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 text-sm px-3 py-2 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs font-medium mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[#94a3b8] text-xs font-medium mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-medium py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[#64748b] text-xs mt-4">
|
||||
NixServer v0.2.0 · Port 2087
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Cpu, CheckCircle, XCircle, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface PhpVersion { version: string; installed: boolean; active: boolean }
|
||||
interface AccountPhp { id: number; username: string; domain: string; package: { phpVersion: string } | null }
|
||||
|
||||
export default function PHP() {
|
||||
const [versions, setVersions] = useState<PhpVersion[]>([])
|
||||
const [accounts, setAccounts] = useState<AccountPhp[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [v, a] = await Promise.all([api.get('/nixserver/php/versions'), api.get('/nixserver/php/accounts')])
|
||||
setVersions(v.data.data)
|
||||
setAccounts(a.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const changeVersion = async (accountId: number, phpVersion: string) => {
|
||||
try {
|
||||
await api.put(`/nixserver/php/accounts/${accountId}`, { phpVersion })
|
||||
fetch()
|
||||
} catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">PHP</h1><p className="text-[#64748b] text-sm">Manage PHP versions per account</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
{/* Installed versions */}
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{versions.map(v => (
|
||||
<div key={v.version} className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm ${v.installed ? 'bg-emerald-500/5 border-emerald-500/20' : 'bg-[#1a1d27] border-[#2a2d3e]'}`}>
|
||||
<Cpu size={14} className={v.installed ? 'text-emerald-400' : 'text-[#64748b]'} />
|
||||
<span className="text-white font-mono">PHP {v.version}</span>
|
||||
{v.installed ? <CheckCircle size={12} className="text-emerald-400" /> : <XCircle size={12} className="text-[#64748b]" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Account PHP versions */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a2d3e]"><h2 className="text-white text-sm font-medium">Account PHP Versions</h2></div>
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Account', 'Domain', 'PHP Version', 'Change'].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{accounts.map(a => (
|
||||
<tr key={a.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 text-white">{a.username}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{a.domain}</td>
|
||||
<td className="px-4 py-3"><span className="font-mono text-indigo-400 text-xs">PHP {a.package?.phpVersion ?? '8.3'}</span></td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
defaultValue={a.package?.phpVersion ?? '8.3'}
|
||||
onChange={e => changeVersion(a.id, e.target.value)}
|
||||
className="bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-xs rounded px-2 py-1 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
{['7.4','8.0','8.1','8.2','8.3'].map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && accounts.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No accounts</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Plus, Pencil, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Package {
|
||||
id: number; name: string; diskMb: number; bandwidthMb: number
|
||||
maxDatabases: number; maxEmailAccounts: number; maxSubdomains: number
|
||||
phpVersion: string; sslEnabled: boolean
|
||||
_count?: { accounts: number }
|
||||
}
|
||||
|
||||
const blank = {
|
||||
name: '', diskMb: 1024, bandwidthMb: 10240,
|
||||
maxDatabases: 5, maxEmailAccounts: 10, maxSubdomains: 10,
|
||||
maxAddonDomains: 5, maxParkedDomains: 5, maxFtpAccounts: 3,
|
||||
maxCronJobs: 5, phpVersion: '8.3', sslEnabled: true, backupEnabled: true,
|
||||
}
|
||||
|
||||
export default function Packages() {
|
||||
const [packages, setPackages] = useState<Package[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<Package | null>(null)
|
||||
const [form, setForm] = useState(blank)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/nixserver/packages'); setPackages(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const openCreate = () => { setEditing(null); setForm(blank); setError(''); setShowModal(true) }
|
||||
const openEdit = (p: Package) => {
|
||||
setEditing(p)
|
||||
setForm({ ...blank, ...p })
|
||||
setError('')
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try {
|
||||
if (editing) await api.put(`/nixserver/packages/${editing.id}`, form)
|
||||
else await api.post('/nixserver/packages', form)
|
||||
setShowModal(false); fetch()
|
||||
} catch (err: unknown) {
|
||||
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Save failed')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const del = async (p: Package) => {
|
||||
if (!confirm(`Delete package "${p.name}"?`)) return
|
||||
try { await api.delete(`/nixserver/packages/${p.id}`); fetch() }
|
||||
catch (err: unknown) {
|
||||
alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
const field = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.type === 'number' ? Number(e.target.value) : e.target.type === 'checkbox' ? (e.target as HTMLInputElement).checked : e.target.value }))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Packages</h1><p className="text-[#64748b] text-sm">{packages.length} hosting plans</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={openCreate} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Package</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{packages.map(p => (
|
||||
<div key={p.id} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-white font-medium">{p.name}</h3>
|
||||
<p className="text-[#64748b] text-xs">{p._count?.accounts ?? 0} accounts</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => openEdit(p)} className="p-1.5 text-[#64748b] hover:text-white rounded"><Pencil size={14} /></button>
|
||||
<button onClick={() => del(p)} className="p-1.5 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{[
|
||||
['Disk', p.diskMb === 0 ? 'Unlimited' : `${(p.diskMb / 1024).toFixed(0)} GB`],
|
||||
['BW', p.bandwidthMb === 0 ? 'Unlimited' : `${(p.bandwidthMb / 1024).toFixed(0)} GB`],
|
||||
['Databases', p.maxDatabases === 0 ? '∞' : p.maxDatabases],
|
||||
['Emails', p.maxEmailAccounts === 0 ? '∞' : p.maxEmailAccounts],
|
||||
['Subdomains', p.maxSubdomains === 0 ? '∞' : p.maxSubdomains],
|
||||
['PHP', p.phpVersion],
|
||||
].map(([l, v]) => (
|
||||
<div key={String(l)} className="flex justify-between">
|
||||
<span className="text-[#64748b]">{l}</span>
|
||||
<span className="text-[#94a3b8]">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{p.sslEnabled && <span className="mt-3 inline-block text-xs px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">SSL</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white font-medium">{editing ? 'Edit Package' : 'New Package'}</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={save} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Name</label>
|
||||
<input value={form.name} onChange={field('name')} required className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
{[
|
||||
['Disk (MB)', 'diskMb'], ['Bandwidth (MB)', 'bandwidthMb'],
|
||||
['Max Databases', 'maxDatabases'], ['Max Email Accounts', 'maxEmailAccounts'],
|
||||
['Max Subdomains', 'maxSubdomains'], ['Max Addon Domains', 'maxAddonDomains'],
|
||||
['Max FTP Accounts', 'maxFtpAccounts'], ['Max Cron Jobs', 'maxCronJobs'],
|
||||
].map(([label, key]) => (
|
||||
<div key={key}><label className="block text-[#94a3b8] text-xs mb-1">{label} (0 = unlimited)</label>
|
||||
<input type="number" min={0} value={form[key as keyof typeof form] as number} onChange={field(key as keyof typeof form)} className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
))}
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">PHP Version</label>
|
||||
<select value={form.phpVersion} onChange={field('phpVersion')} className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
|
||||
{['7.4','8.0','8.1','8.2','8.3'].map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select></div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#2a2d3e] text-[#94a3b8] text-sm py-2 rounded hover:bg-[#1e2130]">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Saving...' : 'Save'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { Plus, Search, RefreshCw, UserX, UserCheck, Trash2 } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Reseller { id: number; username: string; email: string; company: string | null; status: string; _count?: { accounts: number } }
|
||||
|
||||
export default function Resellers() {
|
||||
const [items, setItems] = useState<Reseller[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [form, setForm] = useState({ username: '', password: '', email: '', company: '', maxAccounts: 10, diskLimitMb: 51200, bandwidthLimitMb: 512000 })
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = search ? `?search=${encodeURIComponent(search)}` : ''
|
||||
const r = await api.get(`/nixserver/resellers${params}`)
|
||||
setItems(r.data.data.items ?? r.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}, [search])
|
||||
|
||||
useEffect(() => { fetch() }, [fetch])
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try { await api.post('/nixserver/resellers', form); setShowModal(false); fetch() }
|
||||
catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const act = async (id: number, action: 'suspend' | 'unsuspend' | 'delete') => {
|
||||
if (action === 'delete' && !confirm('Delete this reseller?')) return
|
||||
try {
|
||||
if (action === 'suspend') await api.post(`/nixserver/resellers/${id}/suspend`)
|
||||
else if (action === 'unsuspend') await api.post(`/nixserver/resellers/${id}/unsuspend`)
|
||||
else await api.delete(`/nixserver/resellers/${id}`)
|
||||
fetch()
|
||||
} catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Resellers</h1><p className="text-[#64748b] text-sm">{items.length} resellers</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Reseller</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search resellers..." className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-3 py-2 focus:outline-none focus:border-indigo-500" />
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Username', 'Email', 'Company', 'Accounts', 'Status', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{items.map(r => (
|
||||
<tr key={r.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 text-white">{r.username}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{r.email}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{r.company ?? '—'}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{r._count?.accounts ?? 0}</td>
|
||||
<td className="px-4 py-3"><span className={`text-xs px-2 py-0.5 rounded-full border ${r.status === 'active' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-amber-500/10 text-amber-400 border-amber-500/20'}`}>{r.status}</span></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
{r.status === 'active'
|
||||
? <button onClick={() => act(r.id, 'suspend')} className="p-1 text-[#64748b] hover:text-amber-400 rounded"><UserX size={14} /></button>
|
||||
: <button onClick={() => act(r.id, 'unsuspend')} className="p-1 text-[#64748b] hover:text-emerald-400 rounded"><UserCheck size={14} /></button>}
|
||||
<button onClick={() => act(r.id, 'delete')} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && <tr><td colSpan={6} className="text-center text-[#64748b] py-8">No resellers</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white font-medium">New Reseller</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={save} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
{[['Username', 'username', 'text'], ['Password', 'password', 'password'], ['Email', 'email', 'email'], ['Company', 'company', 'text']].map(([label, key, type]) => (
|
||||
<div key={key}><label className="block text-[#94a3b8] text-xs mb-1">{label}</label>
|
||||
<input type={type} value={form[key as keyof typeof form] as string} required={key !== 'company'}
|
||||
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
))}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#2a2d3e] text-[#94a3b8] text-sm py-2 rounded hover:bg-[#1e2130]">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Creating...' : 'Create'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Lock, Plus, RefreshCw, RotateCcw, XCircle } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Cert { id: number; domain: string; type: string; status: string; issuedAt: string | null; expiresAt: string | null; account: { username: string } }
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
pending: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
|
||||
expired: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
revoked: 'bg-[#1e2130] text-[#64748b] border-[#2a2d3e]',
|
||||
}
|
||||
|
||||
export default function SSL() {
|
||||
const [certs, setCerts] = useState<Cert[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [hosting, setHosting] = useState<{ id: number; username: string; domain: string }[]>([])
|
||||
const [form, setForm] = useState({ accountId: '', domain: '', includeWww: true, autoRenew: true })
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [sl, acc] = await Promise.all([api.get('/nixserver/ssl'), api.get('/nixserver/accounts?limit=100')])
|
||||
setCerts(sl.data.data)
|
||||
setHosting(acc.data.data.items)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const issue = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true); setError('')
|
||||
try {
|
||||
await api.post('/nixserver/ssl/issue', { ...form, accountId: parseInt(form.accountId, 10) })
|
||||
setShowModal(false); fetch()
|
||||
} catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const renew = async (id: number) => {
|
||||
try { await api.post(`/nixserver/ssl/${id}/renew`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Renewal failed') }
|
||||
}
|
||||
|
||||
const revoke = async (id: number) => {
|
||||
if (!confirm('Revoke this certificate?')) return
|
||||
try { await api.post(`/nixserver/ssl/${id}/revoke`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Revoke failed') }
|
||||
}
|
||||
|
||||
const daysUntil = (date: string | null) => {
|
||||
if (!date) return null
|
||||
const days = Math.ceil((new Date(date).getTime() - Date.now()) / 86400000)
|
||||
return days
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">SSL Certificates</h1><p className="text-[#64748b] text-sm">{certs.length} certificates</p></div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> Issue Cert</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Domain', 'Account', 'Status', 'Expires', 'Actions'].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{certs.map(c => {
|
||||
const days = daysUntil(c.expiresAt)
|
||||
return (
|
||||
<tr key={c.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 text-white flex items-center gap-2"><Lock size={12} className="text-emerald-400 flex-shrink-0" />{c.domain}</td>
|
||||
<td className="px-4 py-3 text-[#64748b]">{c.account.username}</td>
|
||||
<td className="px-4 py-3"><span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[c.status] ?? ''}`}>{c.status}</span></td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
{c.expiresAt ? (
|
||||
<span className={days !== null && days < 14 ? 'text-red-400' : days !== null && days < 30 ? 'text-amber-400' : 'text-[#64748b]'}>
|
||||
{days !== null && days > 0 ? `${days}d` : 'Expired'} · {new Date(c.expiresAt).toLocaleDateString()}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
{c.status === 'active' && <button onClick={() => renew(c.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-400 hover:bg-indigo-600/10 rounded border border-indigo-500/20"><RotateCcw size={12} /> Renew</button>}
|
||||
{c.status !== 'revoked' && <button onClick={() => revoke(c.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><XCircle size={14} /></button>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{!loading && certs.length === 0 && <tr><td colSpan={5} className="text-center text-[#64748b] py-8">No certificates</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white font-medium">Issue SSL Certificate</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-[#64748b] hover:text-white">✕</button>
|
||||
</div>
|
||||
<form onSubmit={issue} className="p-4 space-y-3">
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 text-xs px-3 py-2 rounded">{error}</div>}
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Account</label>
|
||||
<select value={form.accountId} onChange={e => setForm(f => ({ ...f, accountId: e.target.value, domain: hosting.find(h => h.id === parseInt(e.target.value, 10))?.domain ?? '' }))} required className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
|
||||
<option value="">Select account...</option>
|
||||
{hosting.map(h => <option key={h.id} value={h.id}>{h.username} ({h.domain})</option>)}
|
||||
</select></div>
|
||||
<div><label className="block text-[#94a3b8] text-xs mb-1">Domain</label>
|
||||
<input value={form.domain} onChange={e => setForm(f => ({ ...f, domain: e.target.value }))} required className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-indigo-500" /></div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={form.includeWww} onChange={e => setForm(f => ({ ...f, includeWww: e.target.checked }))} className="accent-indigo-500" />
|
||||
<span className="text-[#94a3b8] text-xs">Include www. variant</span>
|
||||
</label>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#2a2d3e] text-[#94a3b8] text-sm py-2 rounded hover:bg-[#1e2130]">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Issuing...' : 'Issue'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Shield, Plus, Trash2, CheckCircle } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface BlockedIp { id: number; ip: string; reason: string | null; createdAt: string }
|
||||
interface SecurityEvent { id: number; type: string; severity: string; message: string; sourceIp: string | null; resolved: boolean; createdAt: string }
|
||||
|
||||
const severityColor: Record<string, string> = {
|
||||
info: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
warning: 'text-amber-400 bg-amber-500/10 border-amber-500/20',
|
||||
critical: 'text-red-400 bg-red-500/10 border-red-500/20',
|
||||
}
|
||||
|
||||
export default function Security() {
|
||||
const [blocked, setBlocked] = useState<BlockedIp[]>([])
|
||||
const [events, setEvents] = useState<SecurityEvent[]>([])
|
||||
const [newIp, setNewIp] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [b, e] = await Promise.all([
|
||||
api.get('/nixserver/security/blocked-ips'),
|
||||
api.get('/nixserver/security/events'),
|
||||
])
|
||||
setBlocked(b.data.data)
|
||||
setEvents(e.data.data.items ?? e.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const block = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
try { await api.post('/nixserver/security/blocked-ips', { ip: newIp, reason }); setNewIp(''); setReason(''); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
}
|
||||
|
||||
const unblock = async (id: number) => {
|
||||
try { await api.delete(`/nixserver/security/blocked-ips/${id}`); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
}
|
||||
|
||||
const resolve = async (id: number) => {
|
||||
try { await api.post(`/nixserver/security/events/${id}/resolve`); fetch() }
|
||||
catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div><h1 className="text-white text-xl font-semibold">Security</h1><p className="text-[#64748b] text-sm">IP blocking, events, and Fail2Ban</p></div>
|
||||
|
||||
{/* Block IP */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4">
|
||||
<h2 className="text-white text-sm font-medium mb-3">Block IP Address</h2>
|
||||
<form onSubmit={block} className="flex gap-3">
|
||||
<input value={newIp} onChange={e => setNewIp(e.target.value)} placeholder="192.168.1.1" required
|
||||
className="bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 w-48 focus:outline-none focus:border-indigo-500" />
|
||||
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="Reason (optional)"
|
||||
className="bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 flex-1 focus:outline-none focus:border-indigo-500" />
|
||||
<button type="submit" className="flex items-center gap-2 bg-red-600/20 hover:bg-red-600/30 text-red-400 border border-red-500/30 text-sm px-3 py-2 rounded-lg">
|
||||
<Plus size={14} /> Block
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Blocked IPs */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a2d3e]"><h2 className="text-white text-sm font-medium">Blocked IPs ({blocked.length})</h2></div>
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['IP', 'Reason', 'Blocked', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-2 text-xs">{h}</th>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{blocked.map(b => (
|
||||
<tr key={b.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-2 font-mono text-red-400 text-xs">{b.ip}</td>
|
||||
<td className="px-4 py-2 text-[#64748b] text-xs">{b.reason ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-[#64748b] text-xs">{new Date(b.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-4 py-2"><button onClick={() => unblock(b.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
{blocked.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-6 text-sm">No blocked IPs</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Security Events */}
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a2d3e]"><h2 className="text-white text-sm font-medium">Security Events</h2></div>
|
||||
<div className="divide-y divide-[#2a2d3e]">
|
||||
{events.slice(0, 20).map(ev => (
|
||||
<div key={ev.id} className={`flex items-start gap-3 px-4 py-3 hover:bg-[#1e2130] ${ev.resolved ? 'opacity-50' : ''}`}>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border mt-0.5 flex-shrink-0 ${severityColor[ev.severity] ?? ''}`}>{ev.severity}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[#94a3b8] text-xs">{ev.message}</p>
|
||||
<p className="text-[#64748b] text-xs mt-0.5">{ev.sourceIp && <span className="font-mono">{ev.sourceIp} · </span>}{new Date(ev.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
{!ev.resolved && (
|
||||
<button onClick={() => resolve(ev.id)} className="flex-shrink-0 p-1 text-[#64748b] hover:text-emerald-400 rounded"><CheckCircle size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && <div className="text-center text-[#64748b] py-6 text-sm">No security events</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Play, Square, RefreshCw, AlertCircle, CheckCircle, MinusCircle } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface Service { name: string; status: 'running' | 'stopped' | 'failed' | 'unknown'; enabled: boolean }
|
||||
|
||||
const statusIcon = {
|
||||
running: <CheckCircle size={14} className="text-emerald-400" />,
|
||||
stopped: <MinusCircle size={14} className="text-[#64748b]" />,
|
||||
failed: <AlertCircle size={14} className="text-red-400" />,
|
||||
unknown: <MinusCircle size={14} className="text-[#64748b]" />,
|
||||
}
|
||||
|
||||
export default function Services() {
|
||||
const [services, setServices] = useState<Service[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [acting, setActing] = useState<string | null>(null)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/nixserver/services'); setServices(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const act = async (name: string, action: string) => {
|
||||
setActing(name + action)
|
||||
try { await api.post(`/nixserver/services/${name}`, { action }); fetch() }
|
||||
catch (e: unknown) { alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
|
||||
finally { setActing(null) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Services</h1><p className="text-[#64748b] text-sm">Manage system services</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['Service', 'Status', 'Startup', 'Actions'].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{services.map(s => (
|
||||
<tr key={s.name} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-3 text-white font-mono text-xs">{s.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{statusIcon[s.status]}
|
||||
<span className={s.status === 'running' ? 'text-emerald-400' : s.status === 'failed' ? 'text-red-400' : 'text-[#64748b]'}>
|
||||
{s.status}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${s.enabled ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-[#1e2130] text-[#64748b] border-[#2a2d3e]'}`}>
|
||||
{s.enabled ? 'enabled' : 'disabled'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
{s.status !== 'running' && (
|
||||
<button onClick={() => act(s.name, 'start')} disabled={!!acting} className="px-2 py-1 text-xs bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 rounded hover:bg-emerald-500/20">
|
||||
{acting === s.name + 'start' ? '...' : 'Start'}
|
||||
</button>
|
||||
)}
|
||||
{s.status === 'running' && (
|
||||
<button onClick={() => act(s.name, 'stop')} disabled={!!acting} className="px-2 py-1 text-xs bg-red-500/10 text-red-400 border border-red-500/20 rounded hover:bg-red-500/20">
|
||||
{acting === s.name + 'stop' ? '...' : 'Stop'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => act(s.name, 'restart')} disabled={!!acting} className="px-2 py-1 text-xs bg-[#1e2130] text-[#94a3b8] border border-[#2a2d3e] rounded hover:border-indigo-500">
|
||||
{acting === s.name + 'restart' ? '...' : 'Restart'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && services.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-center text-[#64748b] py-8">No services found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Save, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export default function Settings() {
|
||||
const [settings, setSettings] = useState<Record<string, string>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try { const r = await api.get('/nixserver/system/settings'); setSettings(r.data.data) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault(); setSaving(true)
|
||||
try { await api.put('/nixserver/system/settings', settings); setSaved(true); setTimeout(() => setSaved(false), 2000) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Settings</h1><p className="text-[#64748b] text-sm">System-wide configuration</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-6 space-y-3">
|
||||
{Object.entries(settings).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-4">
|
||||
<label className="text-[#94a3b8] text-xs font-mono w-56 flex-shrink-0">{key}</label>
|
||||
<input
|
||||
value={value}
|
||||
onChange={e => setSettings(s => ({ ...s, [key]: e.target.value }))}
|
||||
className="flex-1 bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-4">
|
||||
<button type="submit" disabled={saving} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm px-4 py-2 rounded-lg">
|
||||
<Save size={14} /> {saving ? 'Saving...' : saved ? 'Saved!' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface SystemInfo { hostname: string; os: string; kernel: string; uptime: string }
|
||||
interface Process { user: string; pid: string; cpu: string; mem: string; command: string }
|
||||
|
||||
export default function System() {
|
||||
const [info, setInfo] = useState<SystemInfo | null>(null)
|
||||
const [processes, setProcesses] = useState<Process[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [infoR, procR] = await Promise.all([
|
||||
api.get('/nixserver/system/info'),
|
||||
api.get('/nixserver/system/processes'),
|
||||
])
|
||||
setInfo(infoR.data.data)
|
||||
setProcesses(procR.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">System</h1><p className="text-[#64748b] text-sm">Server information and processes</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{info && (
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[['Hostname', info.hostname], ['OS', info.os], ['Kernel', info.kernel], ['Uptime', info.uptime]].map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<p className="text-[#64748b] text-xs mb-1">{k}</p>
|
||||
<p className="text-white text-sm font-mono">{v}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white text-sm font-medium">Top Processes</h2>
|
||||
</div>
|
||||
<table className="w-full text-xs">
|
||||
<thead><tr className="border-b border-[#2a2d3e]">
|
||||
{['User', 'PID', 'CPU%', 'MEM%', 'Command'].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-2">{h}</th>
|
||||
))}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{processes.slice(0, 25).map((p, i) => (
|
||||
<tr key={i} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130]">
|
||||
<td className="px-4 py-2 text-[#94a3b8]">{p.user}</td>
|
||||
<td className="px-4 py-2 font-mono text-[#64748b]">{p.pid}</td>
|
||||
<td className="px-4 py-2 text-amber-400">{p.cpu}</td>
|
||||
<td className="px-4 py-2 text-blue-400">{p.mem}</td>
|
||||
<td className="px-4 py-2 text-[#64748b] max-w-xs truncate font-mono">{p.command}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Server, RefreshCw, RotateCcw, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export default function WebServer() {
|
||||
const [status, setStatus] = useState<{ nginx: { active: boolean }; apache: { active: boolean } } | null>(null)
|
||||
const [vhosts, setVhosts] = useState<{ domain: string; path: string }[]>([])
|
||||
const [logs, setLogs] = useState<{ lines: string[] } | null>(null)
|
||||
const [logType, setLogType] = useState<'access' | 'error'>('access')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [testResult, setTestResult] = useState<string | null>(null)
|
||||
|
||||
const fetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [s, v, l] = await Promise.all([
|
||||
api.get('/nixserver/webserver/status'),
|
||||
api.get('/nixserver/webserver/vhosts'),
|
||||
api.get(`/nixserver/webserver/logs/${logType}`),
|
||||
])
|
||||
setStatus(s.data.data); setVhosts(v.data.data); setLogs(l.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [logType])
|
||||
|
||||
const testConfig = async () => {
|
||||
const r = await api.post('/nixserver/webserver/nginx/test')
|
||||
setTestResult(r.data.data.output)
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
try { await api.post('/nixserver/webserver/nginx/reload'); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reload failed') }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div><h1 className="text-white text-xl font-semibold">Web Server</h1><p className="text-[#64748b] text-sm">Nginx and Apache management</p></div>
|
||||
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
|
||||
</div>
|
||||
|
||||
{status && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[['nginx', status.nginx.active], ['apache2', status.apache.active]].map(([name, active]) => (
|
||||
<div key={String(name)} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server size={16} className="text-[#64748b]" />
|
||||
<span className="text-white font-mono text-sm">{name}</span>
|
||||
</div>
|
||||
{active ? <CheckCircle size={16} className="text-emerald-400" /> : <XCircle size={16} className="text-red-400" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button onClick={testConfig} className="flex items-center gap-2 border border-[#2a2d3e] text-[#94a3b8] text-sm px-3 py-2 rounded-lg hover:bg-[#1e2130]">Test Config</button>
|
||||
<button onClick={reload} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-3 py-2 rounded-lg"><RotateCcw size={14} /> Reload nginx</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<pre className="bg-[#0f1117] border border-[#2a2d3e] rounded-xl p-4 text-xs text-[#94a3b8] overflow-x-auto">{testResult}</pre>
|
||||
)}
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[#2a2d3e]">
|
||||
<h2 className="text-white text-sm font-medium">Logs</h2>
|
||||
<div className="flex gap-1">
|
||||
{(['access', 'error'] as const).map(t => (
|
||||
<button key={t} onClick={() => setLogType(t)} className={`px-3 py-1 text-xs rounded ${logType === t ? 'bg-indigo-600 text-white' : 'text-[#64748b] hover:text-white'}`}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<pre className="p-4 text-xs text-[#64748b] font-mono leading-relaxed">
|
||||
{logs?.lines.slice(-100).join('\n') ?? 'Loading...'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user