Files
nixpanel/nixserver/src/App.tsx
T
NixReaper 5247c77132 feat: add Rust backend — workspace with section executables
Cargo workspace with four crates:
- nixpanel-common: shared SectionRequest/SectionResponse types
- nixpanel-api: main Axum HTTP server (JWT auth, DB, routing)
- nixpanel-services: section binary for systemctl start/stop/restart
- nixpanel-sysinfo: section binary for CPU/RAM/disk/OS/version reads

nixpanel-api features:
- POST /api/auth/login — Argon2id verification, HS256 JWT
- GET  /api/dashboard  — real account counts + sysinfo + versions
- GET  /api/services   — live systemctl status via section binary
- POST /api/services/:name/:action — start/stop/restart
- GET  /api/accounts   — list from MariaDB
- POST /api/accounts   — create account + system user

Section binaries are UPX-compressed after build.
DB schema auto-migrated on first start; admin user seeded from
/opt/nixpanel/.admin_user + .admin_pass files.

Frontend changes:
- nixserver/src/api.ts + App.tsx wired to real API (JWT stored in
  localStorage, real data in dashboard/services/accounts)
- nixclient/src/api.ts added
- Both vite configs get /api proxy to localhost:4000 for dev
- install.sh updated to copy + compress all section binaries
2026-04-25 16:41:57 -05:00

755 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react'
import * as api from './api'
import type { DashboardData, ServiceInfo, Account } from './api'
/* ── Types ──────────────────────────────────────────────────────────────── */
type Page =
| 'dashboard' | 'accounts' | 'createAccount' | 'listAccounts'
| 'domains' | 'dns' | 'databases' | 'email'
| 'ssl' | 'backups' | 'firewall'
| 'apache' | 'php' | 'services'
| 'settings' | 'logs' | 'packages'
/* ── Nav ─────────────────────────────────────────────────────────────────── */
type NavGroup = { label: string; items: { id: Page; label: string; icon: string }[] }
const NAV_GROUPS: NavGroup[] = [
{ label: 'Overview', items: [{ id: 'dashboard', label: 'Dashboard', icon: '▦' }] },
{ label: 'Account Management',items: [
{ id: 'listAccounts', label: 'List Accounts', icon: '📋' },
{ id: 'createAccount',label: 'Create Account', icon: '➕' },
{ id: 'packages', label: 'Feature Packages', icon: '📦' },
]},
{ label: 'Domains & DNS', items: [
{ id: 'domains', label: 'Zone Manager', icon: '🌐' },
{ id: 'dns', label: 'DNS Records', icon: '🔗' },
]},
{ label: 'Databases', items: [{ id: 'databases', label: 'MySQL / MariaDB', icon: '🗄️' }] },
{ label: 'Email', items: [{ id: 'email', label: 'Email Routing', icon: '✉️' }] },
{ label: 'Security', items: [
{ id: 'ssl', label: 'SSL / TLS', icon: '🔒' },
{ id: 'firewall', label: 'Firewall (CSF)', icon: '🛡️' },
]},
{ label: 'Server', items: [
{ id: 'apache', label: 'Apache Config', icon: '⚙️' },
{ id: 'php', label: 'PHP Manager', icon: '🐘' },
{ id: 'services', label: 'Service Manager', icon: '🔧' },
{ id: 'backups', label: 'Backup Manager', icon: '💾' },
{ id: 'logs', label: 'System Logs', icon: '📜' },
{ id: 'settings', label: 'Server Config', icon: '🔩' },
]},
]
/* ── Sidebar ────────────────────────────────────────────────────────────── */
function Sidebar({ current, onNav, onLogout, collapsed, onToggle, username }: {
current: Page; onNav: (p: Page) => void; onLogout: () => void
collapsed: boolean; onToggle: () => void; username: string
}) {
return (
<aside className={`flex-shrink-0 bg-[#1a1f2e] border-r border-[#2a3044] flex flex-col min-h-screen transition-all duration-200 ${collapsed ? 'w-14' : 'w-56'}`}>
<div className="h-14 flex items-center justify-between px-3 border-b border-[#2a3044]">
{!collapsed && (
<span className="text-base font-black tracking-tight text-white">
Nix<span className="text-orange-400">Server</span>
<span className="ml-1.5 text-[9px] font-bold bg-orange-500 text-white rounded px-1 py-0.5 uppercase tracking-wide align-middle">WHM</span>
</span>
)}
<button onClick={onToggle} className="text-gray-500 hover:text-white p-1 rounded transition-colors ml-auto" title={collapsed ? 'Expand' : 'Collapse'}>
{collapsed ? '▶' : '◀'}
</button>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{NAV_GROUPS.map(group => (
<div key={group.label} className="mb-1">
{!collapsed && (
<p className="px-3 pt-3 pb-1 text-[9px] font-bold text-gray-600 uppercase tracking-widest">{group.label}</p>
)}
{group.items.map(item => (
<button
key={item.id}
onClick={() => onNav(item.id)}
title={collapsed ? item.label : undefined}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium transition-all ${
current === item.id
? 'bg-orange-500/20 text-orange-400 border-r-2 border-orange-400'
: 'text-gray-400 hover:bg-[#252b3d] hover:text-white'
}`}
>
<span className="text-sm w-4 text-center flex-shrink-0">{item.icon}</span>
{!collapsed && <span className="truncate">{item.label}</span>}
</button>
))}
</div>
))}
</nav>
<div className="border-t border-[#2a3044] p-2">
{!collapsed && (
<div className="flex items-center gap-2 px-1 py-1.5 mb-1">
<div className="w-7 h-7 rounded-full bg-orange-500 flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<p className="text-xs font-semibold text-white truncate">{username}</p>
<p className="text-[10px] text-gray-500">root</p>
</div>
</div>
)}
<button onClick={onLogout} title="Sign out"
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-gray-500 hover:bg-[#252b3d] hover:text-red-400 transition-colors ${collapsed ? 'justify-center' : ''}`}
>
<span>⏻</span>
{!collapsed && 'Sign out'}
</button>
</div>
</aside>
)
}
/* ── Stat bar ────────────────────────────────────────────────────────────── */
function StatBar({ data }: { data: DashboardData | null }) {
const s = data?.stats
const si = data?.sysinfo
const ramPct = si && si.ram_total_mb > 0
? Math.round((si.ram_used_mb / si.ram_total_mb) * 100)
: null
const diskPct = si && si.disk_total_gb > 0
? Math.round((si.disk_used_gb / si.disk_total_gb) * 100)
: null
function barColor(pct: number) {
return pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-emerald-500'
}
const stats = [
{ label: 'Hosting Accounts', value: s?.accounts ?? '—', unit: '', color: 'text-orange-400', bar: null },
{ label: 'Domains', value: s?.domains ?? '—', unit: '', color: 'text-blue-400', bar: null },
{ label: 'Databases', value: s?.databases ?? '—', unit: '', color: 'text-purple-400', bar: null },
{ label: 'Email Accounts', value: s?.email_accounts ?? '—', unit: '', color: 'text-cyan-400', bar: null },
{ label: 'Disk Used', value: si ? `${si.disk_used_gb}` : '—', unit: 'GB', color: diskPct && diskPct > 80 ? 'text-red-400' : 'text-emerald-400', bar: diskPct },
{ label: 'RAM Used', value: si ? `${Math.round(si.ram_used_mb / 1024 * 10) / 10}` : '—', unit: 'GB', color: ramPct && ramPct > 80 ? 'text-red-400' : 'text-yellow-400', bar: ramPct },
]
return (
<div className="grid grid-cols-3 lg:grid-cols-6 gap-0 border border-[#2a3044] rounded-lg overflow-hidden">
{stats.map((s, i) => (
<div key={s.label} className={`bg-[#1a1f2e] px-4 py-3 ${i < stats.length - 1 ? 'border-r border-[#2a3044]' : ''}`}>
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-wider">{s.label}</p>
<p className={`text-xl font-black mt-0.5 ${s.color}`}>
{s.value}<span className="text-xs font-normal text-gray-500 ml-1">{s.unit}</span>
</p>
{s.bar !== null && (
<div className="mt-1.5 h-1 bg-[#2a3044] rounded-full overflow-hidden">
<div className={`h-full rounded-full ${barColor(s.bar)}`} style={{ width: `${s.bar}%` }} />
</div>
)}
</div>
))}
</div>
)
}
/* ── Service table ───────────────────────────────────────────────────────── */
function ServiceTable({ services, loading, onAction }: {
services: ServiceInfo[]
loading: boolean
onAction: (name: string, action: 'start' | 'stop' | 'restart') => void
}) {
const badge: Record<string, string> = {
running: 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30',
stopped: 'bg-red-500/20 text-red-400 border border-red-500/30',
warning: 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30',
}
const running = services.filter(s => s.status === 'running').length
return (
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[#2a3044] flex items-center justify-between">
<h3 className="text-sm font-bold text-white">Service Manager</h3>
{loading
? <span className="text-xs text-gray-600">Loading…</span>
: <span className="text-xs text-gray-500">{running} / {services.length} running</span>
}
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a3044]">
{['Service', 'Status', 'PID', 'Uptime', 'Actions'].map(h => (
<th key={h} className="text-left px-4 py-2 text-[10px] font-bold text-gray-500 uppercase tracking-wider hidden-last-two md:table-cell first:table-cell">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />
Loading services…
</td>
</tr>
) : services.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-600 text-sm">No service data available</td>
</tr>
) : services.map(svc => (
<tr key={svc.name} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
<td className="px-4 py-2.5 text-gray-300 font-medium">{svc.display}</td>
<td className="px-4 py-2.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-semibold ${badge[svc.status] ?? badge.warning}`}>
<span className={`w-1.5 h-1.5 rounded-full ${svc.status === 'running' ? 'bg-emerald-400 animate-pulse' : 'bg-red-400'}`} />
{svc.status.charAt(0).toUpperCase() + svc.status.slice(1)}
</span>
</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.pid ?? '—'}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs hidden md:table-cell">{svc.uptime ?? '—'}</td>
<td className="px-4 py-2.5">
<div className="flex gap-1.5">
<button onClick={() => onAction(svc.name, 'start')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-emerald-500/20 hover:text-emerald-400 text-gray-400 transition-colors">Start</button>
<button onClick={() => onAction(svc.name, 'stop')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-red-500/20 hover:text-red-400 text-gray-400 transition-colors">Stop</button>
<button onClick={() => onAction(svc.name, 'restart')} className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:bg-orange-500/20 hover:text-orange-400 text-gray-400 transition-colors">Restart</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
/* ── Quick action grid ───────────────────────────────────────────────────── */
function QuickActions({ onNav }: { onNav: (p: Page) => void }) {
const actions = [
{ label: 'Create Account', icon: '👤', page: 'createAccount' as Page, desc: 'Add hosting account' },
{ label: 'Zone Manager', icon: '🌐', page: 'domains' as Page, desc: 'Manage DNS zones' },
{ label: 'New Database', icon: '🗄️', page: 'databases' as Page, desc: 'Create MariaDB DB' },
{ label: 'Issue SSL', icon: '🔒', page: 'ssl' as Page, desc: "Let's Encrypt cert" },
{ label: 'Run Backup', icon: '💾', page: 'backups' as Page, desc: 'Full server backup' },
{ label: 'PHP Manager', icon: '🐘', page: 'php' as Page, desc: 'Switch PHP version' },
{ label: 'Firewall', icon: '🛡️', page: 'firewall' as Page, desc: 'Edit CSF rules' },
{ label: 'System Logs', icon: '📜', page: 'logs' as Page, desc: 'Apache, error logs' },
]
return (
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[#2a3044]">
<h3 className="text-sm font-bold text-white">Quick Actions</h3>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-0">
{actions.map((a, i) => (
<button key={a.label} onClick={() => onNav(a.page)}
className={`flex flex-col items-center text-center p-4 hover:bg-orange-500/10 transition-all group ${i % 4 !== 3 ? 'border-r border-[#2a3044]' : ''} ${i < actions.length - 4 ? 'border-b border-[#2a3044]' : ''}`}
>
<span className="text-2xl mb-2 group-hover:scale-110 transition-transform">{a.icon}</span>
<span className="text-xs font-semibold text-gray-300 group-hover:text-white leading-tight">{a.label}</span>
<span className="text-[10px] text-gray-600 group-hover:text-gray-400 mt-0.5 leading-tight">{a.desc}</span>
</button>
))}
</div>
</div>
)
}
/* ── Server info bar ─────────────────────────────────────────────────────── */
function ServerInfoBar({ data }: { data: DashboardData | null }) {
const si = data?.sysinfo
const v = data?.versions
const info = [
{ label: 'Hostname', value: si?.hostname ?? '—' },
{ label: 'OS', value: si?.os ?? '—' },
{ label: 'Kernel', value: si?.kernel ?? '—' },
{ label: 'PHP', value: v?.php ?? '—' },
{ label: 'MariaDB', value: v?.mariadb ?? '—' },
{ label: 'Apache', value: v?.apache ?? '—' },
{ label: 'NixPanel', value: v?.nixpanel ?? '—' },
]
return (
<div className="bg-[#0f1520] border border-[#2a3044] rounded-lg px-4 py-3 flex flex-wrap gap-x-6 gap-y-2">
{info.map(i => (
<div key={i.label} className="flex items-center gap-2">
<span className="text-[10px] font-bold text-gray-600 uppercase tracking-wider">{i.label}:</span>
<span className="text-xs text-gray-300 font-medium">{i.value}</span>
</div>
))}
{si && (
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-gray-600 uppercase tracking-wider">Load:</span>
<span className="text-xs text-gray-300 font-medium">{si.load_avg[0].toFixed(2)}</span>
</div>
)}
</div>
)
}
/* ── Dashboard page ──────────────────────────────────────────────────────── */
function Dashboard({ onNav }: { onNav: (p: Page) => void }) {
const [dashData, setDashData] = useState<DashboardData | null>(null)
const [services, setServices] = useState<ServiceInfo[]>([])
const [svcLoading, setSvcLoading] = useState(true)
const [actionMsg, setActionMsg] = useState('')
useEffect(() => {
api.getDashboard().then(setDashData).catch(console.error)
}, [])
useEffect(() => {
api.getServices().then(s => { setServices(s); setSvcLoading(false) }).catch(() => setSvcLoading(false))
}, [])
const handleServiceAction = useCallback(async (name: string, action: 'start' | 'stop' | 'restart') => {
setActionMsg(`${action}ing ${name}…`)
try {
await api.serviceAction(name, action)
// Refresh services
const updated = await api.getServices()
setServices(updated)
setActionMsg(`${name} ${action}ed`)
} catch (e: any) {
setActionMsg(`Error: ${e.message}`)
}
setTimeout(() => setActionMsg(''), 3000)
}, [])
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-black text-white">Server Overview</h1>
<p className="text-xs text-gray-500 mt-0.5">
{dashData?.sysinfo
? `${dashData.sysinfo.hostname} · ${dashData.sysinfo.ip}`
: 'Welcome back, admin — NixServer WHM'}
</p>
</div>
<div className="flex items-center gap-3">
{actionMsg && (
<span className="text-xs text-orange-400 bg-orange-500/10 border border-orange-500/20 px-3 py-1.5 rounded-full">
{actionMsg}
</span>
)}
<span className="flex items-center gap-1.5 text-xs text-emerald-400 font-semibold bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
All systems operational
</span>
</div>
</div>
<StatBar data={dashData} />
<ServerInfoBar data={dashData} />
<div className="grid grid-cols-1 xl:grid-cols-2 gap-5">
<ServiceTable services={services} loading={svcLoading} onAction={handleServiceAction} />
<QuickActions onNav={onNav} />
</div>
</div>
)
}
/* ── List accounts ───────────────────────────────────────────────────────── */
function ListAccounts({ onNav }: { onNav: (p: Page) => void }) {
const [accounts, setAccounts] = useState<Account[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
useEffect(() => {
api.getAccounts()
.then(setAccounts)
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const filtered = accounts.filter(a =>
a.username.includes(search) || a.domain.includes(search)
)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-black text-white">Hosting Accounts</h1>
<button onClick={() => onNav('createAccount')} className="px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white text-sm font-semibold rounded transition-colors">
+ Create Account
</button>
</div>
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg p-3 flex gap-3">
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
placeholder="Search accounts, domains…"
className="flex-1 bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/50"
/>
<select className="bg-[#0f1520] border border-[#2a3044] text-gray-300 rounded px-3 py-2 text-sm focus:outline-none">
<option>All Status</option><option>Active</option><option>Suspended</option>
</select>
</div>
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#2a3044] bg-[#0f1520]">
{['Username', 'Domain', 'Package', 'Disk Quota', 'Status', 'Created', 'Actions'].map(h => (
<th key={h} className="text-left px-4 py-2.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="px-4 py-12 text-center text-gray-600 text-sm">
<span className="inline-block w-4 h-4 border-2 border-gray-700 border-t-orange-500 rounded-full animate-spin mr-2" />Loading…
</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-16 text-center">
<div className="text-3xl mb-3">👤</div>
<p className="text-gray-500 text-sm">
{search ? 'No accounts match your search.' : <>No accounts yet. <button onClick={() => onNav('createAccount')} className="text-orange-400 hover:underline">Create your first account →</button></>}
</p>
</td></tr>
) : filtered.map(acc => (
<tr key={acc.id} className="border-b border-[#252b3d] hover:bg-[#252b3d]/50 transition-colors">
<td className="px-4 py-2.5 text-gray-300 font-medium">{acc.username}</td>
<td className="px-4 py-2.5 text-gray-400">{acc.domain}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">{acc.package_name}</td>
<td className="px-4 py-2.5 text-gray-500 text-xs">{acc.disk_quota_mb === 0 ? '∞' : `${acc.disk_quota_mb} MB`}</td>
<td className="px-4 py-2.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-semibold ${
acc.status === 'active'
? 'bg-emerald-500/20 text-emerald-400'
: 'bg-red-500/20 text-red-400'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${acc.status === 'active' ? 'bg-emerald-400' : 'bg-red-400'}`} />
{acc.status.charAt(0).toUpperCase() + acc.status.slice(1)}
</span>
</td>
<td className="px-4 py-2.5 text-gray-600 text-xs">{new Date(acc.created_at).toLocaleDateString()}</td>
<td className="px-4 py-2.5">
<div className="flex gap-1.5">
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-orange-400 text-gray-400 transition-colors">Edit</button>
<button className="px-2 py-0.5 rounded text-[11px] bg-[#2a3044] hover:text-red-400 text-gray-400 transition-colors">Suspend</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
/* ── Create account form ─────────────────────────────────────────────────── */
function CreateAccount({ onNav }: { onNav: (p: Page) => void }) {
const [form, setForm] = useState({
username: '', domain: '', email: '', password: '',
package_name: 'Default', disk_quota_mb: 10240, bandwidth_mb: 0,
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const set = (k: string, v: string | number) => setForm(f => ({ ...f, [k]: v }))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true); setError(''); setSuccess('')
try {
const acc = await api.createAccount({
...form,
disk_quota_mb: Number(form.disk_quota_mb),
bandwidth_mb: Number(form.bandwidth_mb),
})
setSuccess(`Account "${acc.username}" created for ${acc.domain}`)
setTimeout(() => onNav('listAccounts'), 1500)
} catch (e: any) {
setError(e.message)
} finally {
setLoading(false)
}
}
const Field = ({ label, name, type = 'text', placeholder = '' }: { label: string; name: string; type?: string; placeholder?: string }) => (
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">{label}</label>
<input type={type} value={String((form as any)[name])} onChange={e => set(name, e.target.value)}
placeholder={placeholder}
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm placeholder-gray-600 focus:outline-none focus:border-orange-500/60"
/>
</div>
)
return (
<div className="max-w-2xl space-y-5">
<h1 className="text-lg font-black text-white">Create Hosting Account</h1>
{error && <div className="px-4 py-3 bg-red-950/60 border border-red-800/50 rounded-lg text-red-400 text-sm">⚠️ {error}</div>}
{success && <div className="px-4 py-3 bg-emerald-950/60 border border-emerald-800/50 rounded-lg text-emerald-400 text-sm">✓ {success}</div>}
<form onSubmit={handleSubmit} className="bg-[#1a1f2e] border border-[#2a3044] rounded-lg divide-y divide-[#2a3044]">
<div className="p-5 space-y-4">
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Domain Information</h2>
<Field label="Domain Name" name="domain" placeholder="example.com" />
</div>
<div className="p-5 space-y-4">
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Account Credentials</h2>
<div className="grid grid-cols-2 gap-4">
<Field label="Username" name="username" placeholder="username" />
<Field label="Password" name="password" type="password" placeholder="••••••••" />
<div className="col-span-2">
<Field label="Email Address" name="email" type="email" placeholder="[email protected]" />
</div>
</div>
</div>
<div className="p-5 space-y-4">
<h2 className="text-xs font-bold text-orange-400 uppercase tracking-widest">Resource Limits</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Package</label>
<select value={form.package_name} onChange={e => set('package_name', e.target.value)}
className="w-full bg-[#0f1520] border border-[#2a3044] text-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60">
<option>Default</option>
<option>Starter</option>
<option>Professional</option>
<option>Enterprise</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Disk Quota (MB)</label>
<input type="number" value={form.disk_quota_mb} onChange={e => set('disk_quota_mb', e.target.value)}
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 mb-1.5">Bandwidth (MB/mo, 0=∞)</label>
<input type="number" value={form.bandwidth_mb} onChange={e => set('bandwidth_mb', e.target.value)}
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded px-3 py-2 text-sm focus:outline-none focus:border-orange-500/60"
/>
</div>
</div>
</div>
<div className="p-5 flex gap-3">
<button type="submit" disabled={loading}
className="px-5 py-2 bg-orange-500 hover:bg-orange-600 disabled:opacity-60 text-white text-sm font-bold rounded transition-colors flex items-center gap-2">
{loading && <span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />}
{loading ? 'Creating…' : 'Create Account'}
</button>
<button type="button" onClick={() => onNav('listAccounts')}
className="px-5 py-2 bg-[#0f1520] border border-[#2a3044] hover:border-gray-500 text-gray-400 hover:text-white text-sm rounded transition-colors">
Cancel
</button>
</div>
</form>
</div>
)
}
/* ── Placeholder ─────────────────────────────────────────────────────────── */
function Placeholder({ title, icon }: { title: string; icon: string }) {
return (
<div className="flex flex-col items-center justify-center h-72 text-center">
<div className="text-5xl mb-4">{icon}</div>
<h2 className="text-lg font-bold text-white mb-2">{title}</h2>
<p className="text-gray-600 text-sm max-w-sm">This module is under active development and will be available in an upcoming release.</p>
<div className="mt-4 px-3 py-1.5 bg-orange-500/10 border border-orange-500/20 rounded text-xs text-orange-400 font-semibold">Coming Soon</div>
</div>
)
}
const PAGE_META: Record<Page, { title: string; icon: string }> = {
dashboard: { title: 'Server Dashboard', icon: '▦' },
accounts: { title: 'Accounts', icon: '👤' },
listAccounts: { title: 'List Accounts', icon: '📋' },
createAccount: { title: 'Create Account', icon: '➕' },
packages: { title: 'Feature Packages', icon: '📦' },
domains: { title: 'Zone Manager', icon: '🌐' },
dns: { title: 'DNS Records', icon: '🔗' },
databases: { title: 'MySQL / MariaDB', icon: '🗄️' },
email: { title: 'Email Routing', icon: '✉️' },
ssl: { title: 'SSL / TLS Manager', icon: '🔒' },
backups: { title: 'Backup Manager', icon: '💾' },
firewall: { title: 'Firewall (CSF)', icon: '🛡️' },
apache: { title: 'Apache Config', icon: '⚙️' },
php: { title: 'PHP Manager', icon: '🐘' },
services: { title: 'Service Manager', icon: '🔧' },
settings: { title: 'Server Config', icon: '🔩' },
logs: { title: 'System Logs', icon: '📜' },
}
/* ── Admin panel shell ───────────────────────────────────────────────────── */
function AdminPanel({ username, onLogout }: { username: string; onLogout: () => void }) {
const [page, setPage] = useState<Page>('dashboard')
const [collapsed, setCollapsed] = useState(false)
const meta = PAGE_META[page]
function renderPage() {
switch (page) {
case 'dashboard': return <Dashboard onNav={setPage} />
case 'listAccounts': return <ListAccounts onNav={setPage} />
case 'createAccount': return <CreateAccount onNav={setPage} />
default: return <Placeholder title={meta.title} icon={meta.icon} />
}
}
return (
<div className="flex min-h-screen bg-[#0f1520] text-white">
<Sidebar current={page} onNav={setPage} onLogout={onLogout} collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} username={username} />
<div className="flex-1 flex flex-col min-w-0">
<header className="h-14 border-b border-[#2a3044] bg-[#1a1f2e] flex items-center px-6 justify-between gap-4 flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-gray-600 text-sm">{meta.icon}</span>
<h2 className="font-semibold text-gray-200 text-sm truncate">{meta.title}</h2>
</div>
<div className="flex items-center gap-3">
<div className="hidden lg:flex items-center gap-2">
<span className="text-xs bg-[#0f1520] border border-[#2a3044] text-gray-500 px-2 py-1 rounded">Ubuntu 24.04</span>
</div>
<div className="flex items-center gap-2 pl-3 border-l border-[#2a3044]">
<div className="w-7 h-7 rounded-full bg-orange-500 flex items-center justify-center text-white text-xs font-bold">{username.charAt(0).toUpperCase()}</div>
<span className="text-sm font-semibold text-gray-300 hidden sm:block">{username}</span>
</div>
</div>
</header>
{page !== 'dashboard' && (
<div className="px-6 py-2 border-b border-[#2a3044] bg-[#0f1520] flex items-center gap-1.5 text-xs text-gray-600">
<button onClick={() => setPage('dashboard')} className="hover:text-orange-400 transition-colors">Dashboard</button>
<span>/</span>
<span className="text-gray-400">{meta.title}</span>
</div>
)}
<main className="flex-1 p-6 overflow-auto">{renderPage()}</main>
<footer className="border-t border-[#2a3044] px-6 py-2 flex items-center justify-between text-[10px] text-gray-700">
<span>NixPanel WHM v0.1.0 · Ubuntu 24.04 LTS</span>
<span>© 2025 NixPanel</span>
</footer>
</div>
</div>
)
}
/* ── Login ───────────────────────────────────────────────────────────────── */
export default function App() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loggedInAs, setLoggedInAs] = useState<string | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
// Restore session from localStorage
useEffect(() => {
if (api.isLoggedIn()) {
const stored = localStorage.getItem('nixpanel_username')
setLoggedInAs(stored ?? 'admin')
}
}, [])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true); setError('')
try {
const result = await api.login(username, password)
localStorage.setItem('nixpanel_username', result.user.username)
setLoggedInAs(result.user.username)
} catch (err: any) {
setError(err.message === 'Unauthorized' ? 'Invalid username or password.' : err.message)
} finally {
setLoading(false)
}
}
const handleLogout = () => {
api.clearToken()
localStorage.removeItem('nixpanel_username')
setLoggedInAs(null)
setUsername(''); setPassword('')
}
if (loggedInAs) return <AdminPanel username={loggedInAs} onLogout={handleLogout} />
return (
<div className="min-h-screen flex bg-[#0f1520]">
{/* Left branding panel */}
<div className="hidden lg:flex flex-col justify-between w-80 bg-[#1a1f2e] border-r border-[#2a3044] p-10">
<div>
<div className="flex items-center gap-3 mb-10">
<div className="w-10 h-10 rounded-lg bg-orange-500 flex items-center justify-center text-white text-xl font-black shadow-lg shadow-orange-900/40">N</div>
<div>
<span className="text-xl font-black text-white">Nix<span className="text-orange-400">Server</span></span>
<div className="text-[10px] font-bold text-gray-600 uppercase tracking-wider">WHM Control Panel</div>
</div>
</div>
<div className="space-y-5">
{[
{ icon: '🌐', title: 'Full DNS Control', desc: 'PowerDNS-backed zone management' },
{ icon: '🔒', title: 'Auto SSL/TLS', desc: "Let's Encrypt for all domains" },
{ icon: '🛡️', title: 'Built-in Firewall', desc: 'CSF/Fail2ban integration' },
{ icon: '💾', title: 'Automated Backups', desc: 'Scheduled full & incremental' },
{ icon: '🐘', title: 'Multi-PHP Support', desc: 'PHP 8.0 – 8.3 per account' },
].map(f => (
<div key={f.title} className="flex items-start gap-3">
<span className="text-lg mt-0.5">{f.icon}</span>
<div>
<p className="text-sm font-semibold text-gray-200">{f.title}</p>
<p className="text-xs text-gray-600">{f.desc}</p>
</div>
</div>
))}
</div>
</div>
<p className="text-[10px] text-gray-700">NixPanel WHM v0.1.0 · Secured by Fail2ban</p>
</div>
{/* Login form */}
<div className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-sm">
<div className="lg:hidden text-center mb-8">
<span className="text-3xl font-black text-white">Nix<span className="text-orange-400">Server</span></span>
<p className="text-xs text-gray-600 mt-1">WHM Control Panel</p>
</div>
<div className="bg-[#1a1f2e] border border-[#2a3044] rounded-xl shadow-2xl overflow-hidden">
<div className="bg-orange-500 px-6 py-4">
<h1 className="text-white font-bold text-base">Administrator Login</h1>
<p className="text-orange-200 text-xs mt-0.5">NixServer WHM · Root Access</p>
</div>
<form onSubmit={handleLogin} className="p-6 space-y-4">
{error && (
<div className="px-4 py-3 bg-red-950/60 border border-red-800/50 rounded-lg text-red-400 text-sm flex items-center gap-2">
<span>⚠️</span> {error}
</div>
)}
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5">Username</label>
<input type="text" value={username} onChange={e => setUsername(e.target.value)} required autoFocus autoComplete="username" placeholder="admin"
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all"
/>
</div>
<div>
<label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" placeholder="••••••••••••"
className="w-full bg-[#0f1520] border border-[#2a3044] text-white rounded-lg px-4 py-2.5 text-sm placeholder-gray-700 focus:outline-none focus:border-orange-500/60 focus:ring-1 focus:ring-orange-500/20 transition-all"
/>
</div>
<button type="submit" disabled={loading}
className="w-full py-2.5 rounded-lg bg-orange-500 hover:bg-orange-600 active:bg-orange-700 disabled:opacity-60 text-white font-bold text-sm transition-all shadow-lg shadow-orange-900/40 flex items-center justify-center gap-2">
{loading ? (
<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />Authenticating…</>
) : 'Sign in to WHM'}
</button>
</form>
<div className="border-t border-[#2a3044] px-6 py-3 bg-[#0f1520] flex items-center justify-between text-[10px] text-gray-700">
<span>Secured by Fail2ban</span>
<span>NixPanel v0.1</span>
</div>
</div>
</div>
</div>
</div>
)
}