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
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NixPanel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@nixpanel/cpanel",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5174",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.7",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"recharts": "^2.13.3"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.7.2",
"vite": "^6.0.1"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
}
+56
View File
@@ -0,0 +1,56 @@
import React from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import Layout from './components/Layout'
import Login from './pages/Login'
import Overview from './pages/Overview'
import FileManager from './pages/FileManager'
import Databases from './pages/Databases'
import Email from './pages/Email'
import SSL from './pages/SSL'
import Domains from './pages/Domains'
import FTP from './pages/FTP'
import CronJobs from './pages/CronJobs'
import Stats from './pages/Stats'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth()
if (loading) return (
<div className="min-h-screen bg-[#0a0f1a] flex items-center justify-center">
<div className="w-6 h-6 border-2 border-sky-500 border-t-transparent rounded-full animate-spin" />
</div>
)
if (!user) return <Navigate to="/login" replace />
return <>{children}</>
}
function AppRoutes() {
const { user } = useAuth()
return (
<Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
<Route path="/" element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route index element={<Overview />} />
<Route path="files" element={<FileManager />} />
<Route path="databases" element={<Databases />} />
<Route path="email" element={<Email />} />
<Route path="ssl" element={<SSL />} />
<Route path="domains" element={<Domains />} />
<Route path="ftp" element={<FTP />} />
<Route path="cron" element={<CronJobs />} />
<Route path="stats" element={<Stats />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
)
}
+34
View File
@@ -0,0 +1,34 @@
import axios from 'axios'
export const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
})
api.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
res => res,
async error => {
const original = error.config
if (error.response?.status === 401 && !original._retry) {
original._retry = true
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) { localStorage.clear(); window.location.href = '/login'; return Promise.reject(error) }
try {
const { data } = await axios.post('/api/auth/refresh', { refreshToken })
localStorage.setItem('access_token', data.data.accessToken)
localStorage.setItem('refresh_token', data.data.refreshToken)
original.headers.Authorization = `Bearer ${data.data.accessToken}`
return api(original)
} catch {
localStorage.clear(); window.location.href = '/login'; return Promise.reject(error)
}
}
return Promise.reject(error)
}
)
+92
View File
@@ -0,0 +1,92 @@
import React, { useState } from 'react'
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import {
LayoutDashboard, FolderOpen, Database, Mail, Lock,
Globe, Anchor, Clock, LogOut, Menu, X, BarChart3, User
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
const nav = [
{ label: 'Overview', to: '/', icon: LayoutDashboard },
{ label: 'File Manager',to: '/files', icon: FolderOpen },
{ label: 'Databases', to: '/databases', icon: Database },
{ label: 'Email', to: '/email', icon: Mail },
{ label: 'SSL', to: '/ssl', icon: Lock },
{ label: 'Domains', to: '/domains', icon: Globe },
{ label: 'FTP Accounts',to: '/ftp', icon: Anchor },
{ label: 'Cron Jobs', to: '/cron', icon: Clock },
{ label: 'Statistics', to: '/stats', icon: BarChart3 },
]
export default function Layout() {
const { user, logout } = useAuth()
const navigate = useNavigate()
const [sidebarOpen, setSidebarOpen] = useState(true)
const handleLogout = async () => { await logout(); navigate('/login') }
return (
<div className="flex h-screen overflow-hidden bg-[#0a0f1a]">
<aside className={`flex flex-col transition-all duration-200 bg-[#111827] border-r border-[#1e2d45] ${sidebarOpen ? 'w-52' : 'w-14'}`}>
<div className="flex items-center gap-2 px-4 py-4 border-b border-[#1e2d45] min-h-[57px]">
<div className="w-7 h-7 rounded-lg bg-sky-500 flex items-center justify-center flex-shrink-0">
<span className="text-white text-xs font-bold">N</span>
</div>
{sidebarOpen && (
<div className="overflow-hidden">
<p className="text-white text-sm font-semibold leading-tight">NixClient</p>
<p className="text-[#64748b] text-xs truncate">{user?.domain}</p>
</div>
)}
</div>
<nav className="flex-1 overflow-y-auto py-2">
{nav.map(({ label, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2 mx-2 my-0.5 rounded-lg text-sm transition-colors ${
isActive ? 'bg-sky-500/20 text-sky-400' : 'text-[#94a3b8] hover:bg-[#1a2235] hover:text-white'
}`
}
>
<Icon size={16} className="flex-shrink-0" />
{sidebarOpen && <span className="truncate">{label}</span>}
</NavLink>
))}
</nav>
<div className="border-t border-[#1e2d45] p-3">
{sidebarOpen && (
<div className="flex items-center gap-2 px-1 mb-2">
<User size={14} className="text-[#64748b] flex-shrink-0" />
<p className="text-white text-xs font-medium truncate">{user?.username}</p>
</div>
)}
<button
onClick={handleLogout}
className="flex items-center gap-2 px-3 py-2 w-full rounded-lg text-[#94a3b8] hover:bg-[#1a2235] hover:text-red-400 transition-colors text-sm"
>
<LogOut size={16} className="flex-shrink-0" />
{sidebarOpen && <span>Logout</span>}
</button>
</div>
</aside>
<div className="flex-1 flex flex-col overflow-hidden">
<header className="flex items-center gap-4 px-6 py-3 bg-[#111827] border-b border-[#1e2d45] min-h-[57px]">
<button onClick={() => setSidebarOpen(v => !v)} className="text-[#64748b] hover:text-white transition-colors">
{sidebarOpen ? <X size={18} /> : <Menu size={18} />}
</button>
<div className="flex-1" />
<span className="text-[#64748b] text-xs font-mono">{user?.domain}</span>
</header>
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
import { api } from '../api/client'
interface User {
id: number
username: string
email: string
domain: string
role: 'user'
}
interface AuthContextValue {
user: User | null
loading: boolean
login: (username: string, password: string) => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const fetchMe = useCallback(async () => {
const token = localStorage.getItem('access_token')
if (!token) { setLoading(false); return }
try {
const { data } = await api.get('/auth/me')
if (data.data.role !== 'user') { localStorage.clear(); setLoading(false); return }
setUser(data.data)
} catch {
localStorage.clear()
} finally {
setLoading(false)
}
}, [])
useEffect(() => { fetchMe() }, [fetchMe])
const login = async (username: string, password: string) => {
const { data } = await api.post('/auth/login', { username, password })
if (data.data.role !== 'user') throw new Error('Use NixServer to log in as admin or reseller')
localStorage.setItem('access_token', data.data.accessToken)
localStorage.setItem('refresh_token', data.data.refreshToken)
setUser(data.data.user)
}
const logout = async () => {
const refreshToken = localStorage.getItem('refresh_token')
try { await api.post('/auth/logout', { refreshToken }) } catch {}
localStorage.clear()
setUser(null)
}
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be inside AuthProvider')
return ctx
}
+31
View File
@@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--bg-primary: #0a0f1a;
--bg-secondary: #111827;
--bg-card: #1a2235;
--border: #1e2d45;
--text-primary: #e2e8f0;
--text-muted: #64748b;
--accent: #0ea5e9;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-secondary); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #2a3d5e; }
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+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>
)
}
+16
View File
@@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
primary: '#0a0f1a',
secondary: '#111827',
card: '#1a2235',
border: '#1e2d45',
accent: '#0ea5e9',
},
},
},
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
server: {
port: 5174,
proxy: {
'/api': { target: 'http://localhost:2087', changeOrigin: true },
'/ws': { target: 'ws://localhost:2087', ws: true },
},
},
})