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:
NixReaper
2026-04-05 10:54:15 -05:00
parent 16245b2d4b
commit 92d08d7b79
84 changed files with 243 additions and 234 deletions
+123
View File
@@ -0,0 +1,123 @@
import React, { useEffect, useState } from 'react'
import { Clock, Plus, Trash2, RefreshCw, ToggleLeft, ToggleRight } from 'lucide-react'
import { api } from '../api/client'
interface CronJob { id: number; schedule: string; command: string; comment: string | null; enabled: boolean }
const PRESETS = [
{ label: 'Every minute', value: '* * * * *' },
{ label: 'Every hour', value: '0 * * * *' },
{ label: 'Daily at midnight', value: '0 0 * * *' },
{ label: 'Weekly (Sunday)', value: '0 0 * * 0' },
{ label: 'Monthly (1st)', value: '0 0 1 * *' },
]
export default function CronJobs() {
const [jobs, setJobs] = useState<CronJob[]>([])
const [loading, setLoading] = useState(false)
const [showModal, setShowModal] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [form, setForm] = useState({ schedule: '0 * * * *', command: '', comment: '', enabled: true })
const fetch = async () => {
setLoading(true)
try { const r = await api.get('/nixclient/cron'); setJobs(r.data.data) }
finally { setLoading(false) }
}
useEffect(() => { fetch() }, [])
const save = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setError('')
try { await api.post('/nixclient/cron', form); setShowModal(false); setForm({ schedule: '0 * * * *', command: '', comment: '', enabled: true }); fetch() }
catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
finally { setSaving(false) }
}
const del = async (id: number) => {
if (!confirm('Delete this cron job?')) return
try { await api.delete(`/nixclient/cron/${id}`); fetch() } catch {}
}
const toggle = async (job: CronJob) => {
try { await api.put(`/nixclient/cron/${job.id}`, { enabled: !job.enabled }); fetch() } catch {}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div><h1 className="text-white text-xl font-semibold">Cron Jobs</h1><p className="text-[#64748b] text-sm">{jobs.length} jobs</p></div>
<div className="flex gap-2">
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Job</button>
</div>
</div>
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1e2d45]">
{['Schedule', 'Command', 'Comment', 'Status', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
</tr></thead>
<tbody>
{jobs.map(j => (
<tr key={j.id} className={`border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235] ${!j.enabled ? 'opacity-50' : ''}`}>
<td className="px-4 py-3 font-mono text-sky-400 text-xs whitespace-nowrap">{j.schedule}</td>
<td className="px-4 py-3 font-mono text-[#94a3b8] text-xs max-w-xs truncate">{j.command}</td>
<td className="px-4 py-3 text-[#64748b] text-xs">{j.comment ?? '—'}</td>
<td className="px-4 py-3">
<button onClick={() => toggle(j)} className="text-[#64748b] hover:text-white">
{j.enabled ? <ToggleRight size={18} className="text-sky-400" /> : <ToggleLeft size={18} />}
</button>
</td>
<td className="px-4 py-3"><button onClick={() => del(j.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
</tr>
))}
{!loading && jobs.length === 0 && <tr><td colSpan={5} className="text-center text-[#64748b] py-8">No cron jobs</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-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-md">
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
<h2 className="text-white font-medium">New Cron Job</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">Preset</label>
<select onChange={e => setForm(f => ({ ...f, schedule: e.target.value }))} className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-[#94a3b8] text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500">
{PRESETS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
<option value="custom">Custom...</option>
</select>
</div>
<div>
<label className="block text-[#94a3b8] text-xs mb-1">Schedule (cron expression)</label>
<input value={form.schedule} onChange={e => setForm(f => ({ ...f, schedule: e.target.value }))} required placeholder="* * * * *"
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white font-mono text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
<p className="text-[#64748b] text-xs mt-1">minute hour day month weekday</p>
</div>
<div>
<label className="block text-[#94a3b8] text-xs mb-1">Command</label>
<input value={form.command} onChange={e => setForm(f => ({ ...f, command: e.target.value }))} required placeholder="/usr/bin/php /home/user/public_html/cron.php"
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white font-mono text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
</div>
<div>
<label className="block text-[#94a3b8] text-xs mb-1">Comment (optional)</label>
<input value={form.comment} onChange={e => setForm(f => ({ ...f, comment: e.target.value }))} placeholder="What this job does"
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Saving...' : 'Create'}</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
import React, { useEffect, useState } from 'react'
import { Database, Plus, Trash2, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react'
import { api } from '../api/client'
interface DbUser { id: number; username: string; host: string; privileges: string }
interface Db { id: number; name: string; charset: string; sizeMb: number; dbUsers: DbUser[] }
export default function Databases() {
const [dbs, setDbs] = useState<Db[]>([])
const [loading, setLoading] = useState(false)
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [showDbModal, setShowDbModal] = useState(false)
const [showUserModal, setShowUserModal] = useState<number | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [dbForm, setDbForm] = useState({ name: '' })
const [userForm, setUserForm] = useState({ username: '', password: '', privileges: 'ALL' })
const fetch = async () => {
setLoading(true)
try { const r = await api.get('/nixclient/databases'); setDbs(r.data.data) }
finally { setLoading(false) }
}
useEffect(() => { fetch() }, [])
const createDb = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setError('')
try { await api.post('/nixclient/databases', dbForm); setShowDbModal(false); setDbForm({ name: '' }); fetch() }
catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
finally { setSaving(false) }
}
const deleteDb = async (id: number) => {
if (!confirm('Delete this database and all its data?')) return
try { await api.delete(`/nixclient/databases/${id}`); fetch() }
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
}
const createUser = async (e: React.FormEvent) => {
e.preventDefault(); if (!showUserModal) return
setSaving(true); setError('')
try { await api.post(`/nixclient/databases/${showUserModal}/users`, userForm); setShowUserModal(null); setUserForm({ username: '', password: '', privileges: 'ALL' }); fetch() }
catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
finally { setSaving(false) }
}
const deleteUser = async (dbId: number, userId: number) => {
try { await api.delete(`/nixclient/databases/${dbId}/users/${userId}`); fetch() }
catch {}
}
const toggle = (id: number) => {
const next = new Set(expanded)
next.has(id) ? next.delete(id) : next.add(id)
setExpanded(next)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div><h1 className="text-white text-xl font-semibold">Databases</h1><p className="text-[#64748b] text-sm">{dbs.length} databases</p></div>
<div className="flex gap-2">
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
<button onClick={() => setShowDbModal(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Database</button>
</div>
</div>
<div className="space-y-2">
{dbs.map(db => (
<div key={db.id} className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
<div className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-[#1a2235]" onClick={() => toggle(db.id)}>
{expanded.has(db.id) ? <ChevronDown size={14} className="text-[#64748b]" /> : <ChevronRight size={14} className="text-[#64748b]" />}
<Database size={14} className="text-sky-400" />
<span className="text-white font-mono text-sm flex-1">{db.name}</span>
<span className="text-[#64748b] text-xs">{db.dbUsers.length} user(s)</span>
<button onClick={e => { e.stopPropagation(); setShowUserModal(db.id) }} className="text-xs text-sky-400 hover:text-sky-300 px-2"><Plus size={12} /> User</button>
<button onClick={e => { e.stopPropagation(); deleteDb(db.id) }} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button>
</div>
{expanded.has(db.id) && db.dbUsers.length > 0 && (
<div className="border-t border-[#1e2d45] px-4 py-2">
{db.dbUsers.map(u => (
<div key={u.id} className="flex items-center justify-between py-1.5">
<div>
<span className="text-[#94a3b8] font-mono text-xs">{u.username}@{u.host}</span>
<span className="ml-3 text-xs text-[#64748b]">{u.privileges}</span>
</div>
<button onClick={() => deleteUser(db.id, u.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={12} /></button>
</div>
))}
</div>
)}
</div>
))}
{!loading && dbs.length === 0 && <div className="text-center text-[#64748b] py-12 text-sm">No databases yet</div>}
</div>
{/* Create DB modal */}
{showDbModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-sm">
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
<h2 className="text-white font-medium">New Database</h2>
<button onClick={() => setShowDbModal(false)} className="text-[#64748b] hover:text-white">✕</button>
</div>
<form onSubmit={createDb} 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">Database Name</label>
<p className="text-[#64748b] text-xs mb-2">Will be prefixed with your username</p>
<input value={dbForm.name} onChange={e => setDbForm({ name: e.target.value })} required placeholder="mydb"
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowDbModal(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded hover:bg-[#1a2235]">Cancel</button>
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? 'Creating...' : 'Create'}</button>
</div>
</form>
</div>
</div>
)}
{/* Create User modal */}
{showUserModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-sm">
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
<h2 className="text-white font-medium">Add Database User</h2>
<button onClick={() => setShowUserModal(null)} className="text-[#64748b] hover:text-white">✕</button>
</div>
<form onSubmit={createUser} 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']].map(([l, k, t]) => (
<div key={k}>
<label className="block text-[#94a3b8] text-xs mb-1">{l}</label>
<input type={t} value={userForm[k as keyof typeof userForm]} onChange={e => setUserForm(f => ({ ...f, [k]: e.target.value }))} required
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
</div>
))}
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowUserModal(null)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? '...' : 'Add User'}</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
import React, { useEffect, useState } from 'react'
import { Globe, Plus, Trash2, RefreshCw } from 'lucide-react'
import { api } from '../api/client'
interface Item { id: number; fqdn?: string; domain?: string; subdomain?: string; documentRoot?: string }
type Tab = 'subdomains' | 'addon' | 'parked'
export default function Domains() {
const [tab, setTab] = useState<Tab>('subdomains')
const [items, setItems] = useState<Item[]>([])
const [loading, setLoading] = useState(false)
const [showModal, setShowModal] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [form, setForm] = useState({ subdomain: '', domain: '' })
const fetch = async () => {
setLoading(true)
try {
const r = await api.get(`/nixclient/domains/${tab}`)
setItems(r.data.data)
} finally { setLoading(false) }
}
useEffect(() => { fetch() }, [tab])
const save = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setError('')
try {
if (tab === 'subdomains') await api.post('/nixclient/domains/subdomains', { subdomain: form.subdomain })
else if (tab === 'addon') await api.post('/nixclient/domains/addon', { domain: form.domain })
else await api.post('/nixclient/domains/parked', { domain: form.domain })
setShowModal(false); setForm({ subdomain: '', domain: '' }); fetch()
} catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
finally { setSaving(false) }
}
const del = async (id: number) => {
if (!confirm('Remove this domain?')) return
try {
if (tab === 'subdomains') await api.delete(`/nixclient/domains/subdomains/${id}`)
else if (tab === 'addon') await api.delete(`/nixclient/domains/addon/${id}`)
else await api.delete(`/nixclient/domains/parked/${id}`)
fetch()
} catch {}
}
const tabs: { key: Tab; label: string }[] = [
{ key: 'subdomains', label: 'Subdomains' },
{ key: 'addon', label: 'Addon Domains' },
{ key: 'parked', label: 'Parked Domains' },
]
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-white text-xl font-semibold">Domains</h1>
<div className="flex gap-2">
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> Add</button>
</div>
</div>
<div className="flex gap-1 bg-[#111827] border border-[#1e2d45] rounded-lg p-1 w-fit">
{tabs.map(t => (
<button key={t.key} onClick={() => setTab(t.key)} className={`px-4 py-1.5 text-sm rounded ${tab === t.key ? 'bg-sky-500 text-white' : 'text-[#64748b] hover:text-white'}`}>
{t.label}
</button>
))}
</div>
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1e2d45]">
{['Domain / FQDN', 'Document Root', ''].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(item => (
<tr key={item.id} className="border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235]">
<td className="px-4 py-3 text-white flex items-center gap-2"><Globe size={12} className="text-sky-400 flex-shrink-0" />{item.fqdn ?? item.domain}</td>
<td className="px-4 py-3 text-[#64748b] font-mono text-xs">{item.documentRoot ?? '—'}</td>
<td className="px-4 py-3"><button onClick={() => del(item.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
</tr>
))}
{!loading && items.length === 0 && <tr><td colSpan={3} className="text-center text-[#64748b] py-8">No {tab} yet</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-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-sm">
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
<h2 className="text-white font-medium">Add {tabs.find(t => t.key === tab)?.label.slice(0, -1)}</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>}
{tab === 'subdomains' ? (
<div><label className="block text-[#94a3b8] text-xs mb-1">Subdomain name</label>
<input value={form.subdomain} onChange={e => setForm(f => ({ ...f, subdomain: e.target.value }))} required placeholder="blog"
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /></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 placeholder="example.com"
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /></div>
)}
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? '...' : 'Add'}</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
import React, { useEffect, useState } from 'react'
import { Mail, Plus, Trash2, RefreshCw, ArrowRight } from 'lucide-react'
import { api } from '../api/client'
interface EmailAccount { id: number; address: string; quotaMb: number; usedMb: number; status: string }
interface Forwarder { id: number; source: string; destination: string }
export default function Email() {
const [accounts, setAccounts] = useState<EmailAccount[]>([])
const [forwarders, setForwarders] = useState<Forwarder[]>([])
const [tab, setTab] = useState<'accounts' | 'forwarders'>('accounts')
const [loading, setLoading] = useState(false)
const [showModal, setShowModal] = useState(false)
const [showFwd, setShowFwd] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [form, setForm] = useState({ username: '', password: '', quotaMb: 500 })
const [fwdForm, setFwdForm] = useState({ source: '', destination: '' })
const fetch = async () => {
setLoading(true)
try {
const [em, fw] = await Promise.all([api.get('/nixclient/email'), api.get('/nixclient/email/forwarders')])
setAccounts(em.data.data)
setForwarders(fw.data.data)
} finally { setLoading(false) }
}
useEffect(() => { fetch() }, [])
const createAccount = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setError('')
try {
await api.post('/nixclient/email', form)
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) => {
if (!confirm('Delete this email account?')) return
try { await api.delete(`/nixclient/email/${id}`); fetch() } catch {}
}
const addForwarder = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setError('')
try { await api.post('/nixclient/email/forwarders', fwdForm); setShowFwd(false); fetch() }
catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed') }
finally { setSaving(false) }
}
const delFwd = async (id: number) => {
try { await api.delete(`/nixclient/email/forwarders/${id}`); fetch() } catch {}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-white text-xl font-semibold">Email</h1>
<div className="flex gap-2">
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
{tab === 'accounts'
? <button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Account</button>
: <button onClick={() => setShowFwd(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New Forwarder</button>}
</div>
</div>
<div className="flex gap-1 bg-[#111827] border border-[#1e2d45] rounded-lg p-1 w-fit">
{(['accounts', 'forwarders'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} className={`px-4 py-1.5 text-sm rounded ${tab === t ? 'bg-sky-500 text-white' : 'text-[#64748b] hover:text-white'}`}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{tab === 'accounts' ? (
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1e2d45]">
{['Address', 'Quota', 'Used', ''].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-[#1e2d45] last:border-0 hover:bg-[#1a2235]">
<td className="px-4 py-3 text-white"><Mail size={12} className="inline mr-2 text-sky-400" />{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"><button onClick={() => del(a.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
</tr>
))}
{!loading && accounts.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No email accounts</td></tr>}
</tbody>
</table>
</div>
) : (
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1e2d45]">
{['Source', '', 'Destination', ''].map((h, i) => <th key={i} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>)}
</tr></thead>
<tbody>
{forwarders.map(f => (
<tr key={f.id} className="border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235]">
<td className="px-4 py-3 text-[#94a3b8]">{f.source}</td>
<td className="px-4 py-3"><ArrowRight size={12} className="text-[#64748b]" /></td>
<td className="px-4 py-3 text-white">{f.destination}</td>
<td className="px-4 py-3"><button onClick={() => delFwd(f.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
</tr>
))}
{!loading && forwarders.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No forwarders</td></tr>}
</tbody>
</table>
</div>
)}
{(showModal || showFwd) && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-sm">
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
<h2 className="text-white font-medium">{showModal ? 'New Email Account' : 'New Forwarder'}</h2>
<button onClick={() => { setShowModal(false); setShowFwd(false) }} className="text-[#64748b] hover:text-white">✕</button>
</div>
{showModal ? (
<form onSubmit={createAccount} 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">Username</label>
<input value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))} required placeholder="info"
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-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-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /></div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? '...' : 'Create'}</button>
</div>
</form>
) : (
<form onSubmit={addForwarder} 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">Source</label>
<input value={fwdForm.source} onChange={e => setFwdForm(f => ({ ...f, source: e.target.value }))} required placeholder="[email protected]"
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /></div>
<div><label className="block text-[#94a3b8] text-xs mb-1">Destination</label>
<input value={fwdForm.destination} onChange={e => setFwdForm(f => ({ ...f, destination: e.target.value }))} required placeholder="[email protected]"
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" /></div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowFwd(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? '...' : 'Add'}</button>
</div>
</form>
)}
</div>
</div>
)}
</div>
)
}
+90
View File
@@ -0,0 +1,90 @@
import React, { useEffect, useState } from 'react'
import { Anchor, Plus, Trash2, RefreshCw } from 'lucide-react'
import { api } from '../api/client'
interface FtpAccount { id: number; username: string; homedir: string; quotaMb: number; status: string }
export default function FTP() {
const [accounts, setAccounts] = useState<FtpAccount[]>([])
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: '', homedir: '/', quotaMb: 0 })
const fetch = async () => {
setLoading(true)
try { const r = await api.get('/nixclient/ftp'); setAccounts(r.data.data) }
finally { setLoading(false) }
}
useEffect(() => { fetch() }, [])
const save = async (e: React.FormEvent) => {
e.preventDefault(); setSaving(true); setError('')
try { await api.post('/nixclient/ftp', form); 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) => {
if (!confirm('Delete this FTP account?')) return
try { await api.delete(`/nixclient/ftp/${id}`); fetch() } catch {}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div><h1 className="text-white text-xl font-semibold">FTP 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-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
<button onClick={() => setShowModal(true)} className="flex items-center gap-2 bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg"><Plus size={16} /> New FTP Account</button>
</div>
</div>
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1e2d45]">
{['Username', 'Directory', 'Quota', ''].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-[#1e2d45] last:border-0 hover:bg-[#1a2235]">
<td className="px-4 py-3 text-white"><Anchor size={12} className="inline mr-2 text-sky-400" />{a.username}</td>
<td className="px-4 py-3 font-mono text-[#64748b] text-xs">{a.homedir}</td>
<td className="px-4 py-3 text-[#64748b]">{a.quotaMb === 0 ? 'Unlimited' : `${a.quotaMb} MB`}</td>
<td className="px-4 py-3"><button onClick={() => del(a.id)} className="p-1 text-[#64748b] hover:text-red-400 rounded"><Trash2 size={14} /></button></td>
</tr>
))}
{!loading && accounts.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No FTP 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-[#111827] border border-[#1e2d45] rounded-xl w-full max-w-sm">
<div className="flex items-center justify-between p-4 border-b border-[#1e2d45]">
<h2 className="text-white font-medium">New FTP 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>}
{[['Username', 'username', 'text', 'ftpuser'], ['Password', 'password', 'password', ''], ['Directory', 'homedir', 'text', '/public_html']].map(([l, k, t, p]) => (
<div key={k}>
<label className="block text-[#94a3b8] text-xs mb-1">{l}</label>
<input type={t} value={String(form[k as keyof typeof form])} onChange={e => setForm(f => ({ ...f, [k]: e.target.value }))} required={k !== 'homedir'} placeholder={p}
className="w-full bg-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded px-3 py-2 focus:outline-none focus:border-sky-500" />
</div>
))}
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowModal(false)} className="flex-1 border border-[#1e2d45] text-[#94a3b8] text-sm py-2 rounded">Cancel</button>
<button type="submit" disabled={saving} className="flex-1 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm py-2 rounded">{saving ? '...' : 'Create'}</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
+184
View File
@@ -0,0 +1,184 @@
import React, { useEffect, useState } from 'react'
import { Folder, FileText, ChevronRight, Trash2, Pencil, Plus, Upload, RefreshCw, FolderPlus, Save, X } from 'lucide-react'
import { api } from '../api/client'
interface FileEntry { name: string; type: 'file' | 'directory'; size: number; mtime: string; path: string }
const fmtSize = (b: number) => b > 1024 * 1024 ? `${(b / 1024 / 1024).toFixed(1)} MB` : b > 1024 ? `${(b / 1024).toFixed(0)} KB` : `${b} B`
export default function FileManager() {
const [path, setPath] = useState('/')
const [files, setFiles] = useState<FileEntry[]>([])
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [editing, setEditing] = useState<{ path: string; content: string } | null>(null)
const [saving, setSaving] = useState(false)
const [newDir, setNewDir] = useState('')
const [showNewDir, setShowNewDir] = useState(false)
const loadDir = async (p: string) => {
setLoading(true); setSelected(new Set())
try {
const r = await api.get(`/nixclient/files?path=${encodeURIComponent(p)}`)
setFiles(r.data.data.files); setPath(r.data.data.path)
} catch (e: unknown) {
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed to load directory')
} finally { setLoading(false) }
}
useEffect(() => { loadDir('/') }, [])
const navigate = (entry: FileEntry) => {
if (entry.type === 'directory') loadDir(entry.path)
else openFile(entry.path)
}
const openFile = async (filePath: string) => {
try {
const r = await api.get(`/nixclient/files/content?path=${encodeURIComponent(filePath)}`)
setEditing({ path: filePath, content: r.data.data.content })
} catch (e: unknown) {
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Cannot open file')
}
}
const saveFile = async () => {
if (!editing) return
setSaving(true)
try {
await api.put('/nixclient/files/content', { path: editing.path, content: editing.content })
setEditing(null)
} catch (e: unknown) {
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Save failed')
} finally { setSaving(false) }
}
const deleteSelected = async () => {
if (!selected.size || !confirm(`Delete ${selected.size} item(s)?`)) return
try {
await api.delete('/nixclient/files', { data: { paths: Array.from(selected) } })
loadDir(path)
} catch {}
}
const createDir = async (e: React.FormEvent) => {
e.preventDefault()
try {
await api.post('/nixclient/files/mkdir', { path: `${path}/${newDir}` })
setNewDir(''); setShowNewDir(false); loadDir(path)
} catch (e: unknown) {
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed')
}
}
// Breadcrumb
const parts = path.split('/').filter(Boolean)
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h1 className="text-white text-xl font-semibold">File Manager</h1>
<div className="flex gap-2">
{selected.size > 0 && (
<button onClick={deleteSelected} className="flex items-center gap-1.5 text-xs text-red-400 border border-red-500/30 px-3 py-2 rounded-lg hover:bg-red-500/10">
<Trash2 size={14} /> Delete ({selected.size})
</button>
)}
<button onClick={() => setShowNewDir(v => !v)} className="flex items-center gap-1.5 text-xs text-[#94a3b8] border border-[#1e2d45] px-3 py-2 rounded-lg hover:bg-[#1a2235]">
<FolderPlus size={14} /> New Folder
</button>
<button onClick={() => loadDir(path)} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
</button>
</div>
</div>
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm text-[#64748b]">
<button onClick={() => loadDir('/')} className="hover:text-white">~</button>
{parts.map((part, i) => (
<React.Fragment key={i}>
<ChevronRight size={12} />
<button onClick={() => loadDir('/' + parts.slice(0, i + 1).join('/'))} className="hover:text-white">{part}</button>
</React.Fragment>
))}
</div>
{/* New dir form */}
{showNewDir && (
<form onSubmit={createDir} className="flex gap-2">
<input value={newDir} onChange={e => setNewDir(e.target.value)} placeholder="folder-name" autoFocus required
className="bg-[#111827] border border-[#1e2d45] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-sky-500" />
<button type="submit" className="bg-sky-500 hover:bg-sky-400 text-white text-sm px-3 py-2 rounded-lg">Create</button>
<button type="button" onClick={() => setShowNewDir(false)} className="text-[#64748b] hover:text-white px-2"><X size={16} /></button>
</form>
)}
{/* File list */}
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1e2d45]">
<th className="w-8 px-4 py-2"></th>
{['Name', 'Size', 'Modified', ''].map(h => <th key={h} className="text-left text-[#64748b] font-medium px-4 py-2 text-xs">{h}</th>)}
</tr></thead>
<tbody>
{path !== '/' && (
<tr className="border-b border-[#1e2d45] hover:bg-[#1a2235] cursor-pointer" onClick={() => loadDir(path.split('/').slice(0, -1).join('/') || '/')}>
<td className="px-4 py-2"></td>
<td className="px-4 py-2 text-[#64748b]" colSpan={3}>..</td>
</tr>
)}
{files.map(f => (
<tr key={f.name} className="border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235] cursor-pointer">
<td className="px-4 py-2" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selected.has(f.path)} onChange={e => {
const next = new Set(selected)
e.target.checked ? next.add(f.path) : next.delete(f.path)
setSelected(next)
}} className="accent-sky-500" />
</td>
<td className="px-4 py-2" onClick={() => navigate(f)}>
<span className="flex items-center gap-2">
{f.type === 'directory'
? <Folder size={14} className="text-amber-400 flex-shrink-0" />
: <FileText size={14} className="text-[#64748b] flex-shrink-0" />}
<span className={f.type === 'directory' ? 'text-white' : 'text-[#94a3b8]'}>{f.name}</span>
</span>
</td>
<td className="px-4 py-2 text-[#64748b] text-xs">{f.type === 'file' ? fmtSize(f.size) : '—'}</td>
<td className="px-4 py-2 text-[#64748b] text-xs">{new Date(f.mtime).toLocaleDateString()}</td>
<td className="px-4 py-2">
{f.type === 'file' && (
<button onClick={() => openFile(f.path)} className="p-1 text-[#64748b] hover:text-white rounded"><Pencil size={13} /></button>
)}
</td>
</tr>
))}
{!loading && files.length === 0 && <tr><td colSpan={5} className="text-center text-[#64748b] py-8">Empty directory</td></tr>}
</tbody>
</table>
</div>
{/* File editor modal */}
{editing && (
<div className="fixed inset-0 z-50 flex flex-col bg-[#0a0f1a]">
<div className="flex items-center justify-between px-4 py-3 bg-[#111827] border-b border-[#1e2d45]">
<span className="text-white text-sm font-mono">{editing.path}</span>
<div className="flex gap-2">
<button onClick={saveFile} disabled={saving} className="flex items-center gap-1.5 bg-sky-500 hover:bg-sky-400 disabled:opacity-50 text-white text-sm px-3 py-1.5 rounded-lg">
<Save size={14} /> {saving ? 'Saving...' : 'Save'}
</button>
<button onClick={() => setEditing(null)} className="p-1.5 text-[#64748b] hover:text-white"><X size={18} /></button>
</div>
</div>
<textarea
value={editing.content}
onChange={e => setEditing(ed => ed ? { ...ed, content: e.target.value } : null)}
className="flex-1 bg-[#0a0f1a] text-[#94a3b8] font-mono text-xs p-4 resize-none focus:outline-none"
spellCheck={false}
/>
</div>
)}
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
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) {
setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? (err instanceof Error ? err.message : 'Login failed'))
} finally { setLoading(false) }
}
return (
<div className="min-h-screen bg-[#0a0f1a] flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 rounded-2xl bg-sky-500 flex items-center justify-center mb-3">
<span className="text-white text-xl font-bold">N</span>
</div>
<h1 className="text-white text-xl font-bold">NixClient</h1>
<p className="text-[#64748b] text-sm mt-1">Hosting Control Panel</p>
</div>
<form onSubmit={handleSubmit} className="bg-[#111827] border border-[#1e2d45] 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-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-sky-500" />
</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-[#0a0f1a] border border-[#1e2d45] text-white text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:border-sky-500" />
</div>
<button type="submit" disabled={loading}
className="w-full bg-sky-500 hover:bg-sky-400 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">NixClient v0.2.0 · Port 2083</p>
</div>
</div>
)
}
+89
View File
@@ -0,0 +1,89 @@
import React, { useEffect, useState } from 'react'
import { Database, Mail, Globe, FolderOpen, Lock, Anchor, Clock, HardDrive } from 'lucide-react'
import { api } from '../api/client'
import { useAuth } from '../contexts/AuthContext'
interface Stats {
diskUsedMb: number
diskLimitMb: number
bandwidthUsedMb: number
bandwidthLimitMb: number
counts: {
emailAccounts: number; databases: number; ftpAccounts: number
subdomains: number; addonDomains: number; sslCertificates: number; cronJobs: number
}
limits: {
maxEmailAccounts: number; maxDatabases: number; maxFtpAccounts: number
maxSubdomains: number; maxAddonDomains: number; maxCronJobs: number
} | null
}
function UsageBar({ used, limit, label }: { used: number; limit: number; label: string }) {
const pct = limit === 0 ? 0 : Math.min(100, Math.round((used / limit) * 100))
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-sky-500'
return (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-[#94a3b8]">{label}</span>
<span className="text-[#64748b]">{used} / {limit === 0 ? '∞' : limit} MB {limit > 0 && `(${pct}%)`}</span>
</div>
<div className="h-1.5 bg-[#1e2d45] rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
function CountCard({ icon: Icon, label, used, limit, to }: { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; used: number; limit: number; to: string }) {
return (
<a href={to} className="bg-[#111827] border border-[#1e2d45] rounded-xl p-4 hover:border-sky-500/30 transition-colors block">
<div className="flex items-center justify-between mb-2">
<span className="text-[#64748b] text-xs">{label}</span>
<Icon size={14} className="text-sky-400" />
</div>
<p className="text-white text-2xl font-bold">{used}</p>
{limit > 0 && <p className="text-[#64748b] text-xs mt-0.5">of {limit}</p>}
{limit === 0 && <p className="text-[#64748b] text-xs mt-0.5">unlimited</p>}
</a>
)
}
export default function Overview() {
const { user } = useAuth()
const [stats, setStats] = useState<Stats | null>(null)
useEffect(() => {
api.get('/nixclient/stats').then(r => setStats(r.data.data)).catch(() => {})
}, [])
return (
<div className="space-y-6">
<div>
<h1 className="text-white text-xl font-semibold">Welcome, {user?.username}</h1>
<p className="text-[#64748b] text-sm">{user?.domain}</p>
</div>
{/* Resource usage */}
{stats && (
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl p-4 space-y-4">
<h2 className="text-white text-sm font-medium">Resource Usage</h2>
<UsageBar used={stats.diskUsedMb} limit={stats.diskLimitMb} label="Disk Space" />
<UsageBar used={stats.bandwidthUsedMb} limit={stats.bandwidthLimitMb} label="Bandwidth" />
</div>
)}
{/* Feature grid */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-3">
<CountCard icon={Mail} label="Email Accounts" used={stats.counts.emailAccounts} limit={stats.limits?.maxEmailAccounts ?? 0} to="/email" />
<CountCard icon={Database} label="Databases" used={stats.counts.databases} limit={stats.limits?.maxDatabases ?? 0} to="/databases" />
<CountCard icon={Anchor} label="FTP Accounts" used={stats.counts.ftpAccounts} limit={stats.limits?.maxFtpAccounts ?? 0} to="/ftp" />
<CountCard icon={Globe} label="Subdomains" used={stats.counts.subdomains} limit={stats.limits?.maxSubdomains ?? 0} to="/domains" />
<CountCard icon={Globe} label="Addon Domains" used={stats.counts.addonDomains} limit={stats.limits?.maxAddonDomains ?? 0} to="/domains" />
<CountCard icon={Lock} label="SSL Certs" used={stats.counts.sslCertificates} limit={0} to="/ssl" />
<CountCard icon={Clock} label="Cron Jobs" used={stats.counts.cronJobs} limit={stats.limits?.maxCronJobs ?? 0} to="/cron" />
</div>
)}
</div>
)
}
+78
View File
@@ -0,0 +1,78 @@
import React, { useEffect, useState } from 'react'
import { Lock, RefreshCw, RotateCcw } from 'lucide-react'
import { api } from '../api/client'
interface Cert { id: number; domain: string; status: string; issuedAt: string | null; expiresAt: string | null }
export default function SSL() {
const [certs, setCerts] = useState<Cert[]>([])
const [loading, setLoading] = useState(false)
const fetch = async () => {
setLoading(true)
try { const r = await api.get('/nixserver/ssl'); setCerts(r.data.data) }
finally { setLoading(false) }
}
useEffect(() => { fetch() }, [])
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 days = (d: string | null) => {
if (!d) return null
return Math.ceil((new Date(d).getTime() - Date.now()) / 86400000)
}
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-[#1a2235] text-[#64748b] border-[#1e2d45]',
}
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">Let's Encrypt certificates for your domains</p></div>
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
</div>
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="border-b border-[#1e2d45]">
{['Domain', 'Status', 'Expires', ''].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 d = days(c.expiresAt)
return (
<tr key={c.id} className="border-b border-[#1e2d45] last:border-0 hover:bg-[#1a2235]">
<td className="px-4 py-3 text-white flex items-center gap-2"><Lock size={12} className="text-emerald-400" />{c.domain}</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={d !== null && d < 14 ? 'text-red-400' : d !== null && d < 30 ? 'text-amber-400' : 'text-[#64748b]'}>
{d !== null && d > 0 ? `${d} days` : 'Expired'} ({new Date(c.expiresAt).toLocaleDateString()})
</span>
: '—'}
</td>
<td className="px-4 py-3">
{c.status === 'active' && (
<button onClick={() => renew(c.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-sky-400 border border-sky-500/30 rounded hover:bg-sky-500/10">
<RotateCcw size={11} /> Renew
</button>
)}
</td>
</tr>
)
})}
{!loading && certs.length === 0 && <tr><td colSpan={4} className="text-center text-[#64748b] py-8">No certificates. Contact your host to issue SSL.</td></tr>}
</tbody>
</table>
</div>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import React, { useEffect, useState } from 'react'
import { RefreshCw, HardDrive, Wifi } from 'lucide-react'
import { api } from '../api/client'
interface Stats {
diskUsedMb: number; diskLimitMb: number
bandwidthUsedMb: number; bandwidthLimitMb: number
counts: Record<string, number>
}
function Bar({ label, used, limit, icon: Icon }: { label: string; used: number; limit: number; icon: React.ComponentType<{ size?: number; className?: string }> }) {
const pct = limit === 0 ? 0 : Math.min(100, Math.round((used / limit) * 100))
const color = pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-amber-500' : 'bg-sky-500'
return (
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<Icon size={14} className="text-sky-400" />
<span className="text-white text-sm font-medium">{label}</span>
</div>
<div className="flex justify-between text-xs mb-2 text-[#64748b]">
<span>{used} MB used</span>
<span>{limit === 0 ? 'Unlimited' : `${limit} MB total`} {limit > 0 && `(${pct}%)`}</span>
</div>
<div className="h-2 bg-[#1e2d45] rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
</div>
)
}
export default function Stats() {
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(false)
const fetch = async () => {
setLoading(true)
try { const r = await api.get('/nixclient/stats'); setStats(r.data.data) }
finally { setLoading(false) }
}
useEffect(() => { fetch() }, [])
const labels: Record<string, string> = {
emailAccounts: 'Email Accounts', databases: 'Databases', ftpAccounts: 'FTP Accounts',
subdomains: 'Subdomains', addonDomains: 'Addon Domains', parkedDomains: 'Parked Domains',
sslCertificates: 'SSL Certificates', cronJobs: 'Cron Jobs',
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div><h1 className="text-white text-xl font-semibold">Statistics</h1><p className="text-[#64748b] text-sm">Account resource usage</p></div>
<button onClick={fetch} className="p-2 text-[#64748b] hover:text-white border border-[#1e2d45] rounded-lg"><RefreshCw size={16} className={loading ? 'animate-spin' : ''} /></button>
</div>
{stats ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Bar label="Disk Space" used={stats.diskUsedMb} limit={stats.diskLimitMb} icon={HardDrive} />
<Bar label="Bandwidth" used={stats.bandwidthUsedMb} limit={stats.bandwidthLimitMb} icon={Wifi} />
</div>
<div className="bg-[#111827] border border-[#1e2d45] rounded-xl p-4">
<h2 className="text-white text-sm font-medium mb-4">Feature Usage</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{Object.entries(stats.counts).map(([key, val]) => (
<div key={key} className="text-center">
<p className="text-white text-2xl font-bold">{val}</p>
<p className="text-[#64748b] text-xs mt-0.5">{labels[key] ?? key}</p>
</div>
))}
</div>
</div>
</>
) : (
<div className="text-center text-[#64748b] py-12">{loading ? 'Loading...' : 'No data'}</div>
)}
</div>
)
}