Initial NixPanel Build

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