feat: rename WHM→NixServer, cPanel→NixClient, bump v0.2.0
- Rename frontend workspaces: whm/ → nixserver/, cpanel/ → nixclient/ - Rename server route dirs: routes/whm/ → routes/nixserver/, routes/cpanel/ → routes/nixclient/ - Update all API prefixes: /api/whm → /api/nixserver, /api/cpanel → /api/nixclient - Rename env vars: WHM_PORT → NIXSERVER_PORT, CPANEL_PORT → NIXCLIENT_PORT - Update config properties: whmPort → nixserverPort, cpanelPort → nixclientPort - Update all UI labels, login pages, sidebar branding, and error messages - Update installer and update scripts to match new names - Add cpanel email route (missing from initial scaffold) - Add .gitattributes to enforce LF line endings - Bump version to 0.2.0 across all package.json files
This commit is contained in:
@@ -0,0 +1,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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user