feat: v0.4.0 — Apache service stack, shell access, log viewer, changelog
Service stack (backend): - webserver.ts rewritten: apache2/php-fpm routes replacing nginx; per-account Apache log endpoint; PHP-FPM reload per version; apache2ctl configtest/reload - accounts.ts: added PUT /:id/shell to toggle /bin/bash vs /usr/sbin/nologin, runs usermod -s and writes audit log New UI features (nixserver): - ShellAccess page: grant/revoke SSH access per account via AccountPicker - ApacheLogs page: live log viewer (access/error) per account; replaces stub - NginxLogs: re-exports ApacheLogs (NixPanel uses Apache, not NGINX) - WebServer page: shows apache2, php8.2-fpm, php8.3-fpm status with reload buttons; config test; Apache log viewer Bug fixes: - Accounts.tsx: Lucide icon size prop type widened (string|number); removed unused Users import; dropped duplicate NGINX log card - Removed soon badge from ShellAccess and ApacheLogs entries Project: - CHANGELOG.md created with full history from 0.2.x through 0.4.0 - All packages bumped from 0.3.2 to 0.4.0
This commit is contained in:
+146
-178
@@ -1,198 +1,166 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus, Search, MoreVertical, UserX, UserCheck, Trash2, RefreshCw } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
List, Globe, GitBranch, UserX, HardDrive, BarChart2,
|
||||
Network, Plus, Mail, KeyRound, Gauge, UserCheck, FlaskConical,
|
||||
Terminal, Edit, Lock, FileQuestion, FileText, ArrowLeftRight,
|
||||
RotateCcw, Trash2, TrendingDown, ArrowUpDown, Code2, Copy, Layers
|
||||
} 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[]
|
||||
interface Stats {
|
||||
total: number
|
||||
page: number
|
||||
totalPages: number
|
||||
active: number
|
||||
suspended: number
|
||||
terminated: 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',
|
||||
interface FunctionCard {
|
||||
icon: React.ComponentType<{ size?: number | string; className?: string }>
|
||||
label: string
|
||||
description: string
|
||||
path: string
|
||||
soon?: boolean
|
||||
}
|
||||
|
||||
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 accountInfo: FunctionCard[] = [
|
||||
{ icon: List, label: 'List Accounts', description: 'View and manage all hosting accounts', path: '/accounts/list' },
|
||||
{ icon: Globe, label: 'List Parked Domains', description: 'View all parked domains across accounts', path: '/accounts/parked-domains' },
|
||||
{ icon: GitBranch, label: 'List Subdomains', description: 'View all subdomains across accounts', path: '/accounts/subdomains' },
|
||||
{ icon: UserX, label: 'List Suspended Accounts', description: 'View and manage suspended accounts', path: '/accounts/suspended' },
|
||||
{ icon: HardDrive, label: 'Show Accounts Over Quota', description: 'Accounts that have exceeded their disk quota', path: '/accounts/over-quota' },
|
||||
{ icon: BarChart2, label: 'View Bandwidth Usage', description: 'Bandwidth consumption sorted by usage', path: '/accounts/bandwidth' },
|
||||
]
|
||||
|
||||
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(`/nixserver/accounts?${params}`)
|
||||
setData(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, status])
|
||||
const accountFunctions: FunctionCard[] = [
|
||||
{ icon: Network, label: 'Change Site\'s IP Address', description: 'Assign a different IP to an account', path: '/accounts/change-ip' },
|
||||
{ icon: Plus, label: 'Create a New Account', description: 'Create a new hosting account', path: '/accounts/create' },
|
||||
{ icon: Mail, label: 'Email All Users', description: 'Send a bulk email to all account holders', path: '/accounts/email-users', soon: true },
|
||||
{ icon: KeyRound, label: 'Force Password Change', description: 'Force users to change password on next login', path: '/accounts/force-password', soon: true },
|
||||
{ icon: Gauge, label: 'Limit Bandwidth Usage', description: 'Set per-account bandwidth caps independent of package', path: '/accounts/limit-bandwidth', soon: true },
|
||||
{ icon: UserCheck, label: 'Manage Account Suspension', description: 'Suspend or unsuspend a hosting account', path: '/accounts/suspension' },
|
||||
{ icon: FlaskConical, label: 'Manage Demo Mode', description: 'Put accounts into read-only demo mode', path: '/accounts/demo-mode', soon: true },
|
||||
{ icon: Terminal, label: 'Manage Shell Access', description: 'Grant or revoke SSH shell access', path: '/accounts/shell-access' },
|
||||
{ icon: Edit, label: 'Modify an Account', description: 'Edit account email, notes, or package', path: '/accounts/modify' },
|
||||
{ icon: Lock, label: 'Password Modification', description: 'Change the password for a hosting account', path: '/accounts/password' },
|
||||
{ icon: FileQuestion, label: 'Quota Modification', description: 'Adjust disk and bandwidth quotas', path: '/accounts/quota' },
|
||||
{ icon: FileText, label: 'Apache Log Viewer', description: 'View Apache access and error logs per account', path: '/accounts/apache-logs' },
|
||||
{ icon: ArrowLeftRight,label: 'Rearrange an Account', description: 'Move an account between resellers', path: '/accounts/rearrange', soon: true },
|
||||
{ icon: RotateCcw, label: 'Reset Account Bandwidth', description: 'Reset bandwidth counters for accounts', path: '/accounts/reset-bandwidth' },
|
||||
{ icon: Trash2, label: 'Terminate Accounts', description: 'Permanently terminate a hosting account', path: '/accounts/terminate' },
|
||||
{ icon: TrendingDown, label: 'Unsuspend Bandwidth Exceeders',description: 'Unsuspend accounts suspended for bandwidth', path: '/accounts/unsuspend-bandwidth' },
|
||||
{ icon: ArrowUpDown, label: 'Upgrade / Downgrade an Account',description: 'Change the hosting package for an account', path: '/accounts/upgrade' },
|
||||
{ icon: Code2, label: 'Web Template Editor', description: 'Edit default HTML templates for new accounts', path: '/accounts/web-template', soon: true },
|
||||
]
|
||||
|
||||
useEffect(() => { fetch() }, [fetch])
|
||||
const multiAccountFunctions: FunctionCard[] = [
|
||||
{ icon: Copy, label: 'Change Multiple Sites\' IP Addresses', description: 'Batch-change IP addresses across multiple accounts', path: '/accounts/bulk-ip', soon: true },
|
||||
{ icon: Layers, label: 'Modify / Upgrade Multiple Accounts', description: 'Apply changes to multiple accounts simultaneously', path: '/accounts/bulk-modify', soon: true },
|
||||
]
|
||||
|
||||
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(`/nixserver/accounts/${id}/suspend`)
|
||||
else if (act === 'unsuspend') await api.post(`/nixserver/accounts/${id}/unsuspend`)
|
||||
else await api.delete(`/nixserver/accounts/${id}`)
|
||||
fetch()
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
}
|
||||
}
|
||||
interface CategoryProps {
|
||||
title: string
|
||||
color: string
|
||||
headerBg: string
|
||||
cards: FunctionCard[]
|
||||
}
|
||||
|
||||
function Category({ title, color, headerBg, cards }: CategoryProps) {
|
||||
const navigate = useNavigate()
|
||||
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 className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className={`px-5 py-3 ${headerBg} border-b border-[#2a2d3e]`}>
|
||||
<h2 className={`text-sm font-semibold ${color}`}>{title}</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-px bg-[#2a2d3e]">
|
||||
{cards.map(card => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
<button
|
||||
key={card.path}
|
||||
onClick={() => navigate(card.path)}
|
||||
className="relative bg-[#1a1d27] hover:bg-[#1e2130] transition-colors p-4 text-left flex items-start gap-3 group"
|
||||
>
|
||||
{card.soon && (
|
||||
<span className="absolute top-2 right-2 text-[10px] font-semibold bg-orange-500/15 text-orange-400 border border-orange-500/25 px-1.5 py-0.5 rounded-full">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${
|
||||
card.soon
|
||||
? 'bg-orange-500/10'
|
||||
: 'bg-indigo-600/15 group-hover:bg-indigo-600/25'
|
||||
} transition-colors`}>
|
||||
<Icon size={16} className={card.soon ? 'text-orange-400' : 'text-indigo-400'} />
|
||||
</div>
|
||||
<div className="min-w-0 pr-6">
|
||||
<p className="text-white text-sm font-medium leading-tight">{card.label}</p>
|
||||
<p className="text-[#64748b] text-xs mt-0.5 leading-snug">{card.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Accounts() {
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
|
||||
{/* 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>
|
||||
useEffect(() => {
|
||||
api.get('/nixserver/accounts/stats')
|
||||
.then(r => setStats(r.data.data))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Account Functions</h1>
|
||||
<p className="text-[#64748b] text-sm">Manage hosting accounts, domains, and usage.</p>
|
||||
</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>
|
||||
{/* Stats bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'Total Accounts', value: stats?.total ?? '—', color: 'text-white' },
|
||||
{ label: 'Active', value: stats?.active ?? '—', color: 'text-emerald-400' },
|
||||
{ label: 'Suspended', value: stats?.suspended ?? '—', color: 'text-amber-400' },
|
||||
{ label: 'Terminated', value: stats?.terminated ?? '—', color: 'text-red-400' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl px-4 py-3">
|
||||
<p className="text-[#64748b] text-xs mb-1">{s.label}</p>
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<Category
|
||||
title="Account Information"
|
||||
color="text-indigo-400"
|
||||
headerBg="bg-indigo-600/10"
|
||||
cards={accountInfo}
|
||||
/>
|
||||
<Category
|
||||
title="Account Functions"
|
||||
color="text-violet-400"
|
||||
headerBg="bg-violet-600/10"
|
||||
cards={accountFunctions}
|
||||
/>
|
||||
<Category
|
||||
title="Multi Account Functions"
|
||||
color="text-emerald-400"
|
||||
headerBg="bg-emerald-600/10"
|
||||
cards={multiAccountFunctions}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-2 text-xs text-[#64748b]">
|
||||
<span className="bg-orange-500/15 text-orange-400 border border-orange-500/25 px-1.5 py-0.5 rounded-full text-[10px] font-semibold">Soon</span>
|
||||
<span>Feature not yet implemented</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function Login() {
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[#64748b] text-xs mt-4">
|
||||
NixServer v0.3.1 · Port 2087
|
||||
NixServer v0.3.2 · Port 2087
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,7 +220,7 @@ export default function Setup() {
|
||||
</div>
|
||||
|
||||
<p className="text-center text-[#64748b] text-xs mt-4">
|
||||
NixServer v0.3.1 · You can revisit these settings anytime under Settings
|
||||
NixServer v0.3.2 · You can revisit these settings anytime under Settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,54 +2,93 @@ import { useEffect, useState } from 'react'
|
||||
import { Server, RefreshCw, RotateCcw, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
|
||||
interface WebStatus {
|
||||
apache: { active: boolean }
|
||||
php82fpm: { active: boolean }
|
||||
php83fpm: { active: boolean }
|
||||
}
|
||||
|
||||
export default function WebServer() {
|
||||
const [status, setStatus] = useState<{ nginx: { active: boolean }; apache: { active: boolean } } | null>(null)
|
||||
const [, setVhosts] = useState<{ domain: string; path: string }[]>([])
|
||||
const [status, setStatus] = useState<WebStatus | null>(null)
|
||||
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 [reloading, setReloading] = useState(false)
|
||||
|
||||
const fetch = async () => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [s, v, l] = await Promise.all([
|
||||
const [s, l] = await Promise.all([
|
||||
api.get('/nixserver/webserver/status'),
|
||||
api.get('/nixserver/webserver/vhosts'),
|
||||
api.get(`/nixserver/webserver/logs/${logType}`),
|
||||
])
|
||||
setStatus(s.data.data); setVhosts(v.data.data); setLogs(l.data.data)
|
||||
setStatus(s.data.data)
|
||||
setLogs(l.data.data)
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { fetch() }, [logType])
|
||||
useEffect(() => { fetchData() }, [logType])
|
||||
|
||||
const testConfig = async () => {
|
||||
const r = await api.post('/nixserver/webserver/nginx/test')
|
||||
setTestResult(r.data.data.output)
|
||||
try {
|
||||
const r = await api.post('/nixserver/webserver/apache/test')
|
||||
setTestResult(r.data.data.output)
|
||||
} catch (err: unknown) {
|
||||
setTestResult((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Test failed')
|
||||
}
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
try { await api.post('/nixserver/webserver/nginx/reload'); fetch() }
|
||||
catch (err: unknown) { alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reload failed') }
|
||||
const reload = async (service: 'apache' | 'fpm82' | 'fpm83') => {
|
||||
setReloading(true)
|
||||
try {
|
||||
if (service === 'apache') {
|
||||
await api.post('/nixserver/webserver/apache/reload')
|
||||
} else {
|
||||
await api.post('/nixserver/webserver/fpm/reload', { version: service === 'fpm82' ? '8.2' : '8.3' })
|
||||
}
|
||||
fetchData()
|
||||
} catch (err: unknown) {
|
||||
alert((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reload failed')
|
||||
} finally { setReloading(false) }
|
||||
}
|
||||
|
||||
const services: { key: keyof WebStatus; label: string; reload?: 'apache' | 'fpm82' | 'fpm83' }[] = [
|
||||
{ key: 'apache', label: 'apache2', reload: 'apache' },
|
||||
{ key: 'php82fpm', label: 'php8.2-fpm', reload: 'fpm82' },
|
||||
{ key: 'php83fpm', label: 'php8.3-fpm', reload: 'fpm83' },
|
||||
]
|
||||
|
||||
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><h1 className="text-white text-xl font-semibold">Web Server</h1><p className="text-[#64748b] text-sm">Apache and PHP-FPM management</p></div>
|
||||
<button onClick={fetchData} 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="grid grid-cols-3 gap-4">
|
||||
{services.map(({ key, label, reload: reloadKey }) => (
|
||||
<div key={key} 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>
|
||||
<span className="text-white font-mono text-sm">{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{status[key].active
|
||||
? <CheckCircle size={16} className="text-emerald-400" />
|
||||
: <XCircle size={16} className="text-red-400" />}
|
||||
{reloadKey && (
|
||||
<button
|
||||
onClick={() => reload(reloadKey)}
|
||||
disabled={reloading}
|
||||
title={`Reload ${label}`}
|
||||
className="p-1 text-[#64748b] hover:text-indigo-400 rounded disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{active ? <CheckCircle size={16} className="text-emerald-400" /> : <XCircle size={16} className="text-red-400" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -57,16 +96,18 @@ export default function WebServer() {
|
||||
|
||||
<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>
|
||||
<button onClick={() => reload('apache')} disabled={reloading} 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">
|
||||
<RotateCcw size={14} /> Reload Apache
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<pre className="bg-[#0f1117] border border-[#2a2d3e] rounded-xl p-4 text-xs text-[#94a3b8] overflow-x-auto">{testResult}</pre>
|
||||
<pre className="bg-[#0f1117] border border-[#2a2d3e] rounded-xl p-4 text-xs text-[#94a3b8] overflow-x-auto whitespace-pre-wrap">{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>
|
||||
<h2 className="text-white text-sm font-medium">Apache 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>
|
||||
@@ -74,7 +115,7 @@ export default function WebServer() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<pre className="p-4 text-xs text-[#64748b] font-mono leading-relaxed">
|
||||
<pre className="p-4 text-xs text-[#64748b] font-mono leading-relaxed whitespace-pre-wrap">
|
||||
{logs?.lines.slice(-100).join('\n') ?? 'Loading...'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Search, User } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export interface PickedAccount {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
status: string
|
||||
ipAddress: string | null
|
||||
package?: { name: string }
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSelect: (account: PickedAccount) => void
|
||||
selected?: PickedAccount | null
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
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 AccountPicker({ onSelect, selected, placeholder = 'Search by username, domain, or email...' }: Props) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [results, setResults] = useState<PickedAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) { setResults([]); setOpen(false); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get(`/nixserver/accounts?search=${encodeURIComponent(q)}&limit=10`)
|
||||
setResults(r.data.data.items)
|
||||
setOpen(true)
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInput = (val: string) => {
|
||||
setSearch(val)
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => doSearch(val), 300)
|
||||
}
|
||||
|
||||
const handleSelect = (acc: PickedAccount) => {
|
||||
onSelect(acc)
|
||||
setSearch('')
|
||||
setResults([])
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-[#64748b]" />
|
||||
{loading && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
<input
|
||||
value={search}
|
||||
onChange={e => handleInput(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full bg-[#1a1d27] border border-[#2a2d3e] text-white text-sm rounded-lg pl-9 pr-9 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#1e2130] border border-[#2a2d3e] rounded-lg shadow-xl overflow-hidden">
|
||||
{results.map(acc => (
|
||||
<button
|
||||
key={acc.id}
|
||||
onClick={() => handleSelect(acc)}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-[#2a2d3e] transition-colors text-left border-b border-[#2a2d3e] last:border-0"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-indigo-600/20 flex items-center justify-center flex-shrink-0">
|
||||
<User size={14} className="text-indigo-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium">{acc.username}</p>
|
||||
<p className="text-[#64748b] text-xs truncate">{acc.domain} — {acc.email}</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border flex-shrink-0 ${statusColor[acc.status] ?? ''}`}>
|
||||
{acc.status}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{open && !loading && results.length === 0 && search.trim() && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#1e2130] border border-[#2a2d3e] rounded-lg shadow-xl px-4 py-3">
|
||||
<p className="text-[#64748b] text-sm">No accounts found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="bg-[#1a1d27] border border-indigo-500/30 rounded-lg p-4 flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-indigo-600/20 flex items-center justify-center flex-shrink-0">
|
||||
<User size={18} className="text-indigo-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium">{selected.username}</p>
|
||||
<p className="text-[#64748b] text-xs truncate">{selected.domain} — {selected.email}</p>
|
||||
{selected.package && (
|
||||
<p className="text-[#64748b] text-xs">Package: {selected.package.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border flex-shrink-0 ${statusColor[selected.status] ?? ''}`}>
|
||||
{selected.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, FileText, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function ApacheLogs() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [logType, setLogType] = useState<'access' | 'error'>('access')
|
||||
const [lines, setLines] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetchLogs = async (username: string, type: 'access' | 'error') => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const r = await api.get(`/nixserver/webserver/accounts/${username}/logs/${type}`)
|
||||
setLines(r.data.data.lines ?? [])
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed to load logs')
|
||||
setLines([])
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const handleSelect = (acc: PickedAccount) => {
|
||||
setSelected(acc)
|
||||
setLines([])
|
||||
setError('')
|
||||
fetchLogs(acc.username, logType)
|
||||
}
|
||||
|
||||
const handleTabChange = (type: 'access' | 'error') => {
|
||||
setLogType(type)
|
||||
if (selected) fetchLogs(selected.username, type)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Apache Log Viewer</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Apache Log Viewer</h1>
|
||||
<p className="text-[#64748b] text-sm">View Apache access and error logs per hosting account.</p>
|
||||
</div>
|
||||
{selected && (
|
||||
<button
|
||||
onClick={() => fetchLogs(selected.username, logType)}
|
||||
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 p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-3 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={14} className="text-indigo-400" />
|
||||
<span className="text-[#94a3b8] text-sm font-mono">/home/{selected.username}/logs/</span>
|
||||
</div>
|
||||
<div className="flex gap-1 bg-[#0f1117] border border-[#2a2d3e] rounded-lg p-0.5">
|
||||
{(['access', 'error'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => handleTabChange(t)}
|
||||
className={`px-3 py-1 text-xs rounded ${logType === t ? 'bg-indigo-600 text-white' : 'text-[#64748b] hover:text-white'}`}
|
||||
>
|
||||
{t}.log
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 className="bg-[#0f1117] border border-[#2a2d3e] rounded-xl overflow-hidden">
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32 text-[#64748b] text-sm">Loading logs...</div>
|
||||
) : lines.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-[#64748b] text-sm">No log entries found</div>
|
||||
) : (
|
||||
<pre className="p-4 text-xs text-[#64748b] font-mono leading-relaxed whitespace-pre-wrap">
|
||||
{lines.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
{lines.length > 0 && (
|
||||
<div className="border-t border-[#2a2d3e] px-4 py-2 text-xs text-[#64748b]">
|
||||
Showing last {lines.length} lines
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface BandwidthAccount {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
status: string
|
||||
bandwidthUsedMb: number
|
||||
package?: { name: string; bandwidthMb: number }
|
||||
}
|
||||
|
||||
export default function BandwidthUsage() {
|
||||
const [items, setItems] = useState<BandwidthAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/bandwidth')
|
||||
setItems(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">View Bandwidth Usage</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">View Bandwidth Usage</h1>
|
||||
<p className="text-[#64748b] text-sm">{items.length} account{items.length !== 1 ? 's' : ''}, sorted by usage</p>
|
||||
</div>
|
||||
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<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]">
|
||||
{['Username', 'Domain', 'Bandwidth Used', 'Bandwidth Limit', '% 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>
|
||||
{loading && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && items.map(acc => {
|
||||
const limit = acc.package?.bandwidthMb ?? 0
|
||||
const pct = limit > 0 ? Math.min(100, Math.round((acc.bandwidthUsedMb / limit) * 100)) : 0
|
||||
const barColor = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-indigo-500'
|
||||
return (
|
||||
<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>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">{acc.bandwidthUsedMb.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">
|
||||
{limit > 0 ? `${limit.toLocaleString()} MB` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 min-w-[140px]">
|
||||
{limit > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-[#2a2d3e] rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${barColor}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[#94a3b8] text-xs font-mono">{pct}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[#64748b] text-xs">No limit</span>
|
||||
)}
|
||||
</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>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No accounts found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Network, Clock } from 'lucide-react'
|
||||
|
||||
export default function BulkIP() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Change Multiple Sites' IP Addresses</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Change Multiple Sites' IP Addresses</h1>
|
||||
<p className="text-[#64748b] text-sm">Batch-change IP addresses across multiple accounts.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Network size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Change Multiple Sites' IP Addresses</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to batch-reassign IP addresses across multiple hosting accounts simultaneously, useful for IP migrations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Users, Clock } from 'lucide-react'
|
||||
|
||||
export default function BulkModify() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Modify / Upgrade Multiple Accounts</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Modify / Upgrade Multiple Accounts</h1>
|
||||
<p className="text-[#64748b] text-sm">Apply package upgrades or setting changes to multiple accounts simultaneously.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Users size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Modify / Upgrade Multiple Accounts</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to apply package upgrades, setting changes, or bulk modifications to multiple hosting accounts at once.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Network } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function ChangeIP() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [ipAddress, setIpAddress] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSelect = (acc: PickedAccount) => {
|
||||
setSelected(acc)
|
||||
setIpAddress(acc.ipAddress ?? '')
|
||||
setMessage('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected || !ipAddress.trim()) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${selected.id}/change-ip`, { ipAddress: ipAddress.trim() })
|
||||
setMessage(`IP address for "${selected.username}" updated to ${ipAddress.trim()}.`)
|
||||
setSelected(prev => prev ? { ...prev, ipAddress: ipAddress.trim() } : null)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Change Site's IP Address</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Change Site's IP Address</h1>
|
||||
<p className="text-[#64748b] text-sm">Assign a different IP address to a hosting account.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
|
||||
<p className="text-[#64748b] text-xs mb-1">Current IP Address</p>
|
||||
<p className="text-white font-mono">{selected.ipAddress ?? 'Not assigned'}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">New IP Address</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ipAddress}
|
||||
onChange={e => setIpAddress(e.target.value)}
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white font-mono text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !ipAddress.trim()}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Network size={16} /> {loading ? 'Updating...' : 'Update IP Address'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, FlaskConical, Clock } from 'lucide-react'
|
||||
|
||||
export default function DemoMode() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Manage Demo Mode</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Manage Demo Mode</h1>
|
||||
<p className="text-[#64748b] text-sm">Put accounts into read-only demo mode.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<FlaskConical size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Manage Demo Mode</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to put hosting accounts into a read-only demo mode, preventing any changes or file modifications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Mail, Clock } from 'lucide-react'
|
||||
|
||||
export default function EmailUsers() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Email All Users</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Email All Users</h1>
|
||||
<p className="text-[#64748b] text-sm">Send a bulk email to all account holders.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Mail size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Email All Users</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to compose and send a bulk email to all hosting account holders simultaneously.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, KeyRound, Clock } from 'lucide-react'
|
||||
|
||||
export default function ForcePassword() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Force Password Change</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Force Password Change</h1>
|
||||
<p className="text-[#64748b] text-sm">Force users to change their password on next login.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<KeyRound size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Force Password Change</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to flag one or more accounts to require a password change on their next login.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Gauge, Clock } from 'lucide-react'
|
||||
|
||||
export default function LimitBandwidth() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Limit Bandwidth Usage</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Limit Bandwidth Usage</h1>
|
||||
<p className="text-[#64748b] text-sm">Set per-account bandwidth caps independent of package.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Gauge size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Limit Bandwidth Usage</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to set custom per-account bandwidth caps that override the package-level limits.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus, Search, MoreVertical, UserX, UserCheck, Trash2, RefreshCw, ChevronLeft } 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 ListAccounts() {
|
||||
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 fetchData = 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(`/nixserver/accounts?${params}`)
|
||||
setData(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, status])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const handleAction = 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(`/nixserver/accounts/${id}/suspend`)
|
||||
else if (act === 'unsuspend') await api.post(`/nixserver/accounts/${id}/unsuspend`)
|
||||
else await api.delete(`/nixserver/accounts/${id}`)
|
||||
fetchData()
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">List Accounts</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">List Accounts</h1>
|
||||
<p className="text-[#64748b] text-sm">{data?.total ?? 0} total accounts</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={fetchData} 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={() => handleAction(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={() => handleAction(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={() => handleAction(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>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Save } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
interface Package {
|
||||
id: number
|
||||
name: string
|
||||
diskMb: number
|
||||
bandwidthMb: number
|
||||
}
|
||||
|
||||
export default function Modify() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [packageId, setPackageId] = useState<string>('')
|
||||
const [packages, setPackages] = useState<Package[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/nixserver/packages').then(r => setPackages(r.data.data?.items ?? r.data.data ?? [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSelect = (acc: PickedAccount) => {
|
||||
setSelected(acc)
|
||||
setEmail(acc.email)
|
||||
setNotes('')
|
||||
setPackageId('')
|
||||
setMessage('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
const body: Record<string, unknown> = {}
|
||||
if (email && email !== selected.email) body.email = email
|
||||
if (notes) body.notes = notes
|
||||
if (packageId) body.packageId = parseInt(packageId, 10)
|
||||
|
||||
try {
|
||||
await api.put(`/nixserver/accounts/${selected.id}`, body)
|
||||
setMessage(`Account "${selected.username}" has been updated.`)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Modify an Account</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Modify an Account</h1>
|
||||
<p className="text-[#64748b] text-sm">Update account settings such as email, notes, or package.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Package</label>
|
||||
<select
|
||||
value={packageId}
|
||||
onChange={e => setPackageId(e.target.value)}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">— Keep current ({selected.package?.name ?? 'none'}) —</option>
|
||||
{packages.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Internal notes about this account..."
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save size={16} /> {loading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// This page is an alias for ApacheLogs — NixPanel uses Apache, not NGINX.
|
||||
export { default } from './ApacheLogs'
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface OverQuotaAccount {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
status: string
|
||||
diskUsedMb: number
|
||||
package: { name: string; diskMb: number; bandwidthMb: number }
|
||||
}
|
||||
|
||||
export default function OverQuota() {
|
||||
const [items, setItems] = useState<OverQuotaAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/over-quota')
|
||||
setItems(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Show Accounts Over Quota</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Show Accounts Over Quota</h1>
|
||||
<p className="text-[#64748b] text-sm">{items.length} account{items.length !== 1 ? 's' : ''} over disk quota</p>
|
||||
</div>
|
||||
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<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]">
|
||||
{['Username', 'Domain', 'Package', 'Disk Used', 'Disk Limit', 'Over By', 'Usage', 'Status'].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={8} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && items.map(acc => {
|
||||
const pct = Math.min(100, Math.round((acc.diskUsedMb / acc.package.diskMb) * 100))
|
||||
const overBy = acc.diskUsedMb - acc.package.diskMb
|
||||
return (
|
||||
<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>
|
||||
</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 text-red-400 font-mono text-xs">{acc.diskUsedMb.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">{acc.package.diskMb.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 text-red-400 font-mono text-xs font-semibold">+{overBy.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 min-w-[120px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-[#2a2d3e] rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-red-500 rounded-full transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-red-400 text-xs font-mono">{pct}%</span>
|
||||
</div>
|
||||
</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>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={8} className="text-center text-[#64748b] py-8">No accounts are over quota</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface ParkedDomain {
|
||||
id: number
|
||||
domain: string
|
||||
accountId: number
|
||||
status: string
|
||||
createdAt: string
|
||||
account: { username: string; domain: string }
|
||||
}
|
||||
|
||||
export default function ParkedDomains() {
|
||||
const [items, setItems] = useState<ParkedDomain[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/parked-domains')
|
||||
setItems(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
inactive: 'bg-[#2a2d3e] text-[#64748b] border-[#2a2d3e]',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">List Parked Domains</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">List Parked Domains</h1>
|
||||
<p className="text-[#64748b] text-sm">{items.length} parked domain{items.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<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]">
|
||||
{['Parked Domain', 'Primary Domain', 'Username', 'Status', '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 && (
|
||||
<tr><td colSpan={5} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && items.map(pd => (
|
||||
<tr key={pd.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3 text-white font-medium">{pd.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{pd.account.domain}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-indigo-400">{pd.account.username}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[pd.status] ?? 'text-[#94a3b8]'}`}>
|
||||
{pd.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(pd.createdAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={5} className="text-center text-[#64748b] py-8">No parked domains found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, KeyRound, Eye, EyeOff } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function PasswordMod() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [showPw, setShowPw] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected) return
|
||||
if (password !== confirm) { setError('Passwords do not match'); return }
|
||||
if (password.length < 8) { setError('Password must be at least 8 characters'); return }
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.put(`/nixserver/accounts/${selected.id}`, { password })
|
||||
setMessage(`Password for "${selected.username}" has been updated.`)
|
||||
setPassword(''); setConfirm('')
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Password Modification</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Password Modification</h1>
|
||||
<p className="text-[#64748b] text-sm">Change the password for a hosting account.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={acc => { setSelected(acc); setPassword(''); setConfirm(''); setMessage(''); setError('') }} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 pr-10 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPw(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#64748b] hover:text-white transition-colors"
|
||||
>
|
||||
{showPw ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Confirm Password</label>
|
||||
<input
|
||||
type={showPw ? 'text' : 'password'}
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
placeholder="Re-enter password"
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !password || !confirm}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<KeyRound size={16} /> {loading ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, HardDrive } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function QuotaMod() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [fullAccount, setFullAccount] = useState<{ diskUsedMb: number; package?: { diskMb: number; bandwidthMb: number } } | null>(null)
|
||||
const [diskMb, setDiskMb] = useState('')
|
||||
const [bandwidthMb, setBandwidthMb] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSelect = async (acc: PickedAccount) => {
|
||||
setSelected(acc)
|
||||
setMessage(''); setError('')
|
||||
try {
|
||||
const r = await api.get(`/nixserver/accounts/${acc.id}`)
|
||||
const data = r.data.data
|
||||
setFullAccount(data)
|
||||
setDiskMb(String(data.package?.diskMb ?? ''))
|
||||
setBandwidthMb(String(data.package?.bandwidthMb ?? ''))
|
||||
} catch {
|
||||
setFullAccount(null)
|
||||
setDiskMb('')
|
||||
setBandwidthMb('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected) return
|
||||
const d = parseInt(diskMb, 10)
|
||||
const b = parseInt(bandwidthMb, 10)
|
||||
if (!d || !b || d <= 0 || b <= 0) { setError('Enter valid positive values for both fields'); return }
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.put(`/nixserver/accounts/${selected.id}/quota`, { diskMb: d, bandwidthMb: b })
|
||||
setMessage(`Quota updated for "${selected.username}".`)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Quota Modification</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Quota Modification</h1>
|
||||
<p className="text-[#64748b] text-sm">Adjust disk and bandwidth quotas for a hosting account.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
{fullAccount && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
|
||||
<p className="text-[#64748b] text-xs mb-1">Disk Used</p>
|
||||
<p className="text-white font-mono text-sm">{fullAccount.diskUsedMb?.toLocaleString() ?? 0} MB</p>
|
||||
</div>
|
||||
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
|
||||
<p className="text-[#64748b] text-xs mb-1">Current Package</p>
|
||||
<p className="text-white text-sm">{selected.package?.name ?? 'None'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Disk Limit (MB)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={diskMb}
|
||||
onChange={e => setDiskMb(e.target.value)}
|
||||
min="1"
|
||||
placeholder="e.g. 10240"
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Bandwidth Limit (MB)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={bandwidthMb}
|
||||
onChange={e => setBandwidthMb(e.target.value)}
|
||||
min="1"
|
||||
placeholder="e.g. 102400"
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[#64748b] text-xs">
|
||||
Note: This will update the quota on the account's associated package.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !diskMb || !bandwidthMb}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<HardDrive size={16} /> {loading ? 'Updating...' : 'Update Quota'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, ArrowLeftRight, Clock } from 'lucide-react'
|
||||
|
||||
export default function Rearrange() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Rearrange an Account</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Rearrange an Account</h1>
|
||||
<p className="text-[#64748b] text-sm">Move an account between resellers or change ownership.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<ArrowLeftRight size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Rearrange an Account</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to move hosting accounts between resellers or reassign ownership to a different reseller.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw, RotateCcw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface BandwidthAccount {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
status: string
|
||||
bandwidthUsedMb: number
|
||||
package?: { name: string; bandwidthMb: number }
|
||||
}
|
||||
|
||||
export default function ResetBandwidth() {
|
||||
const [items, setItems] = useState<BandwidthAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [resetting, setResetting] = useState<Set<number>>(new Set())
|
||||
const [resetAll, setResetAll] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/bandwidth')
|
||||
setItems(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
const handleReset = async (id: number) => {
|
||||
setResetting(prev => new Set(prev).add(id))
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${id}/reset-bandwidth`)
|
||||
setItems(prev => prev.map(a => a.id === id ? { ...a, bandwidthUsedMb: 0 } : a))
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Reset failed')
|
||||
} finally {
|
||||
setResetting(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetAll = async () => {
|
||||
if (!confirm(`Reset bandwidth for all ${items.length} accounts?`)) return
|
||||
setResetAll(true)
|
||||
try {
|
||||
await Promise.all(items.map(a => api.post(`/nixserver/accounts/${a.id}/reset-bandwidth`)))
|
||||
setItems(prev => prev.map(a => ({ ...a, bandwidthUsedMb: 0 })))
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Bulk reset failed')
|
||||
} finally {
|
||||
setResetAll(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Reset Account Bandwidth Limit</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Reset Account Bandwidth Limit</h1>
|
||||
<p className="text-[#64748b] text-sm">Reset bandwidth counters for individual accounts or all at once.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetAll}
|
||||
disabled={resetAll || items.length === 0}
|
||||
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 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw size={15} /> {resetAll ? 'Resetting...' : 'Reset All'}
|
||||
</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]">
|
||||
{['Username', 'Domain', 'Bandwidth Used', 'Limit', 'Status', ''].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && 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 text-white font-medium">{acc.username}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">{acc.bandwidthUsedMb.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">
|
||||
{acc.package?.bandwidthMb ? `${acc.package.bandwidthMb.toLocaleString()} MB` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${
|
||||
acc.status === 'active' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' :
|
||||
acc.status === 'suspended' ? 'bg-amber-500/10 text-amber-400 border-amber-500/20' :
|
||||
'bg-red-500/10 text-red-400 border-red-500/20'
|
||||
}`}>
|
||||
{acc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleReset(acc.id)}
|
||||
disabled={resetting.has(acc.id) || acc.bandwidthUsedMb === 0}
|
||||
className="flex items-center gap-1.5 text-xs bg-indigo-600/10 hover:bg-indigo-600/20 text-indigo-400 border border-indigo-500/20 px-3 py-1.5 rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw size={12} /> {resetting.has(acc.id) ? 'Resetting...' : 'Reset'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No accounts found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Terminal, ShieldCheck, ShieldOff } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function ShellAccess() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [shell, setShell] = useState<string>('/usr/sbin/nologin')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSelect = async (acc: PickedAccount) => {
|
||||
setSelected(acc); setMessage(''); setError('')
|
||||
try {
|
||||
const r = await api.get(`/nixserver/accounts/${acc.id}`)
|
||||
setShell(r.data.data.shell ?? '/usr/sbin/nologin')
|
||||
} catch {
|
||||
setShell('/usr/sbin/nologin')
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (enable: boolean) => {
|
||||
if (!selected) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
const r = await api.put(`/nixserver/accounts/${selected.id}/shell`, { enabled: enable })
|
||||
setShell(r.data.data.shell)
|
||||
setMessage(enable
|
||||
? `Shell access enabled for "${selected.username}".`
|
||||
: `Shell access disabled for "${selected.username}".`)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const hasShell = shell === '/bin/bash' || shell === '/bin/sh'
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Manage Shell Access</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Manage Shell Access</h1>
|
||||
<p className="text-[#64748b] text-sm">Grant or revoke SSH shell access for hosting accounts.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="flex items-center gap-3 bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
|
||||
<Terminal size={16} className={hasShell ? 'text-emerald-400' : 'text-[#64748b]'} />
|
||||
<div className="flex-1">
|
||||
<p className="text-white text-sm font-medium">{selected.username}</p>
|
||||
<p className="text-[#64748b] text-xs font-mono mt-0.5">shell: {shell}</p>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${hasShell
|
||||
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20'
|
||||
: 'bg-[#1a2235] text-[#64748b] border-[#2a2d3e]'}`}>
|
||||
{hasShell ? 'Shell enabled' : 'No shell'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hasShell ? (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 rounded-lg px-4 py-3 text-amber-400 text-xs">
|
||||
This account has SSH access via <span className="font-mono">{shell}</span>. Disabling sets shell to <span className="font-mono">/usr/sbin/nologin</span>.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggle(false)}
|
||||
disabled={loading}
|
||||
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-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ShieldOff size={16} /> {loading ? 'Disabling...' : 'Disable Shell Access'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-lg px-4 py-3 text-[#64748b] text-xs">
|
||||
No shell access. Enabling will set the shell to <span className="font-mono">/bin/bash</span>.
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggle(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ShieldCheck size={16} /> {loading ? 'Enabling...' : 'Enable Shell Access'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface SubdomainItem {
|
||||
id: number
|
||||
subdomain: string
|
||||
domain: string
|
||||
accountId: number
|
||||
documentRoot: string
|
||||
status: string
|
||||
createdAt: string
|
||||
account: { username: string }
|
||||
}
|
||||
|
||||
export default function Subdomains() {
|
||||
const [items, setItems] = useState<SubdomainItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/subdomains')
|
||||
setItems(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
active: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
inactive: 'bg-[#2a2d3e] text-[#64748b] border-[#2a2d3e]',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">List Subdomains</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">List Subdomains</h1>
|
||||
<p className="text-[#64748b] text-sm">{items.length} subdomain{items.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<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]">
|
||||
{['Subdomain', 'Full Domain', 'Username', 'Document Root', 'Status', '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 && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && items.map(sd => (
|
||||
<tr key={sd.id} className="border-b border-[#2a2d3e] last:border-0 hover:bg-[#1e2130] transition-colors">
|
||||
<td className="px-4 py-3 text-white font-medium">{sd.subdomain}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{sd.domain}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-indigo-400">{sd.account.username}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] font-mono text-xs truncate max-w-[200px]">{sd.documentRoot}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${statusColor[sd.status] ?? 'text-[#94a3b8]'}`}>
|
||||
{sd.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">{new Date(sd.createdAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No subdomains found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Search, UserCheck, RefreshCw, ChevronLeft } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface Account {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
email: string
|
||||
status: string
|
||||
suspendReason: string | null
|
||||
suspendedAt: string | null
|
||||
createdAt: string
|
||||
package?: { name: string }
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
items: Account[]
|
||||
total: number
|
||||
page: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export default function SuspendedAccounts() {
|
||||
const [data, setData] = useState<PageData | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: '25', status: 'suspended' })
|
||||
if (search) params.set('search', search)
|
||||
const r = await api.get(`/nixserver/accounts?${params}`)
|
||||
setData(r.data.data)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
const unsuspend = async (id: number) => {
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${id}/unsuspend`)
|
||||
fetchData()
|
||||
} 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 gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">List Suspended Accounts</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">List Suspended Accounts</h1>
|
||||
<p className="text-[#64748b] text-sm">{data?.total ?? 0} suspended accounts</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<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]">
|
||||
{['Username', 'Domain', 'Package', 'Suspend Reason', 'Suspended At', ''].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={6} 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-amber-400 text-xs">
|
||||
{acc.suspendReason ?? 'No reason given'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[#64748b] text-xs">
|
||||
{acc.suspendedAt ? new Date(acc.suspendedAt).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => unsuspend(acc.id)}
|
||||
className="flex items-center gap-1.5 text-xs bg-emerald-600/10 hover:bg-emerald-600/20 text-emerald-400 border border-emerald-500/20 px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
<UserCheck size={13} /> Unsuspend
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data?.items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No suspended accounts</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, UserX, UserCheck } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function Suspension() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [reason, setReason] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSuspend = async () => {
|
||||
if (!selected) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${selected.id}/suspend`, { reason: reason || undefined })
|
||||
setMessage(`Account "${selected.username}" has been suspended.`)
|
||||
setSelected(prev => prev ? { ...prev, status: 'suspended' } : null)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnsuspend = async () => {
|
||||
if (!selected) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${selected.id}/unsuspend`)
|
||||
setMessage(`Account "${selected.username}" has been unsuspended.`)
|
||||
setSelected(prev => prev ? { ...prev, status: 'active' } : null)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Manage Account Suspension</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Manage Account Suspension</h1>
|
||||
<p className="text-[#64748b] text-sm">Search for an account to suspend or unsuspend it.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={setSelected} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
{selected.status === 'active' && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">Suspend Reason (optional)</label>
|
||||
<input
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder="e.g. TOS violation, non-payment..."
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSuspend}
|
||||
disabled={loading}
|
||||
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-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserX size={16} /> {loading ? 'Suspending...' : 'Suspend Account'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.status === 'suspended' && (
|
||||
<button
|
||||
onClick={handleUnsuspend}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-500/30 text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserCheck size={16} /> {loading ? 'Unsuspending...' : 'Unsuspend Account'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selected.status !== 'active' && selected.status !== 'suspended' && (
|
||||
<p className="text-[#64748b] text-sm">
|
||||
This account has status <strong className="text-[#94a3b8]">{selected.status}</strong> and cannot be suspended/unsuspended.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Trash2, AlertTriangle } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
export default function Terminate() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [removeFiles, setRemoveFiles] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleTerminate = async () => {
|
||||
if (!selected) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.delete(`/nixserver/accounts/${selected.id}`, { data: { removeFiles } })
|
||||
setMessage(`Account "${selected.username}" has been terminated.`)
|
||||
setSelected(null)
|
||||
setConfirm('')
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canTerminate = selected && confirm === selected.username
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Terminate Accounts</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Terminate Accounts</h1>
|
||||
<p className="text-[#64748b] text-sm">Permanently terminate a hosting account. This action cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={acc => { setSelected(acc); setConfirm('') }} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertTriangle size={18} className="text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-red-400 text-sm font-medium">Warning: Irreversible Action</p>
|
||||
<p className="text-[#94a3b8] text-xs">
|
||||
Terminating <strong>{selected.username}</strong> will permanently delete the account, DNS zones, email accounts, databases, and SSL certificates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeFiles}
|
||||
onChange={e => setRemoveFiles(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-[#2a2d3e] bg-[#0f1117] accent-red-500"
|
||||
/>
|
||||
<span className="text-[#94a3b8] text-sm">Also delete home directory files</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm">
|
||||
Type <strong className="text-white">{selected.username}</strong> to confirm
|
||||
</label>
|
||||
<input
|
||||
value={confirm}
|
||||
onChange={e => setConfirm(e.target.value)}
|
||||
placeholder={selected.username}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-red-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleTerminate}
|
||||
disabled={!canTerminate || loading}
|
||||
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-4 py-2 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={16} /> {loading ? 'Terminating...' : 'Terminate Account'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, RefreshCw, UserCheck } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
interface BandwidthAccount {
|
||||
id: number
|
||||
username: string
|
||||
domain: string
|
||||
status: string
|
||||
bandwidthUsedMb: number
|
||||
suspendReason: string | null
|
||||
package?: { name: string; bandwidthMb: number }
|
||||
}
|
||||
|
||||
export default function UnsuspendBandwidth() {
|
||||
const [items, setItems] = useState<BandwidthAccount[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [unsuspending, setUnsuspending] = useState<Set<number>>(new Set())
|
||||
const [bulkLoading, setBulkLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await api.get('/nixserver/accounts/bandwidth')
|
||||
const all: BandwidthAccount[] = r.data.data
|
||||
setItems(all.filter(a =>
|
||||
a.status === 'suspended' &&
|
||||
a.suspendReason?.toLowerCase().includes('bandwidth')
|
||||
))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [])
|
||||
|
||||
const handleUnsuspend = async (id: number, username: string) => {
|
||||
setUnsuspending(prev => new Set(prev).add(id))
|
||||
try {
|
||||
await api.post(`/nixserver/accounts/${id}/unsuspend`)
|
||||
setItems(prev => prev.filter(a => a.id !== id))
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Failed')
|
||||
} finally {
|
||||
setUnsuspending(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
void username
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnsuspendAll = async () => {
|
||||
if (!confirm(`Unsuspend all ${items.length} bandwidth-exceeded accounts?`)) return
|
||||
setBulkLoading(true)
|
||||
try {
|
||||
const r = await api.post('/nixserver/accounts/unsuspend-bandwidth-exceeded')
|
||||
const results = r.data.data as { username: string; success: boolean }[]
|
||||
const count = results.filter(x => x.success).length
|
||||
setMessage(`Successfully unsuspended ${count} account${count !== 1 ? 's' : ''}.`)
|
||||
await fetchData()
|
||||
} catch (e: unknown) {
|
||||
alert((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Bulk unsuspend failed')
|
||||
} finally {
|
||||
setBulkLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Unsuspend Bandwidth Exceeders</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Unsuspend Bandwidth Exceeders</h1>
|
||||
<p className="text-[#64748b] text-sm">{items.length} account{items.length !== 1 ? 's' : ''} suspended for bandwidth overage</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={fetchData} className="p-2 text-[#64748b] hover:text-white border border-[#2a2d3e] rounded-lg transition-colors">
|
||||
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUnsuspendAll}
|
||||
disabled={bulkLoading || items.length === 0}
|
||||
className="flex items-center gap-2 bg-emerald-600/20 hover:bg-emerald-600/30 text-emerald-400 border border-emerald-500/30 text-sm px-3 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserCheck size={15} /> {bulkLoading ? 'Unsuspending...' : 'Unsuspend All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
|
||||
<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', 'Bandwidth Used', 'Limit', 'Suspend Reason', ''].map(h => (
|
||||
<th key={h} className="text-left text-[#64748b] font-medium px-4 py-3 text-xs">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">Loading...</td></tr>
|
||||
)}
|
||||
{!loading && 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 text-white font-medium">{acc.username}</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8]">{acc.domain}</td>
|
||||
<td className="px-4 py-3 text-amber-400 font-mono text-xs">{acc.bandwidthUsedMb.toLocaleString()} MB</td>
|
||||
<td className="px-4 py-3 text-[#94a3b8] font-mono text-xs">
|
||||
{acc.package?.bandwidthMb ? `${acc.package.bandwidthMb.toLocaleString()} MB` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-amber-400 text-xs">{acc.suspendReason ?? '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleUnsuspend(acc.id, acc.username)}
|
||||
disabled={unsuspending.has(acc.id)}
|
||||
className="flex items-center gap-1.5 text-xs bg-emerald-600/10 hover:bg-emerald-600/20 text-emerald-400 border border-emerald-500/20 px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserCheck size={12} /> {unsuspending.has(acc.id) ? 'Unsuspending...' : 'Unsuspend'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && items.length === 0 && (
|
||||
<tr><td colSpan={6} className="text-center text-[#64748b] py-8">No bandwidth-exceeded suspended accounts</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, ArrowUpDown } from 'lucide-react'
|
||||
import { api } from '../../api/client'
|
||||
import AccountPicker, { type PickedAccount } from './AccountPicker'
|
||||
|
||||
interface Package {
|
||||
id: number
|
||||
name: string
|
||||
diskMb: number
|
||||
bandwidthMb: number
|
||||
}
|
||||
|
||||
export default function Upgrade() {
|
||||
const [selected, setSelected] = useState<PickedAccount | null>(null)
|
||||
const [packageId, setPackageId] = useState<string>('')
|
||||
const [packages, setPackages] = useState<Package[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/nixserver/packages').then(r => setPackages(r.data.data?.items ?? r.data.data ?? [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSelect = (acc: PickedAccount) => {
|
||||
setSelected(acc)
|
||||
setPackageId('')
|
||||
setMessage('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selected || !packageId) return
|
||||
setLoading(true); setMessage(''); setError('')
|
||||
try {
|
||||
await api.put(`/nixserver/accounts/${selected.id}`, { packageId: parseInt(packageId, 10) })
|
||||
const pkg = packages.find(p => p.id === parseInt(packageId, 10))
|
||||
setMessage(`Package for "${selected.username}" changed to "${pkg?.name ?? packageId}".`)
|
||||
if (pkg) setSelected(prev => prev ? { ...prev, package: { name: pkg.name } } : null)
|
||||
} catch (e: unknown) {
|
||||
setError((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? 'Action failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Upgrade / Downgrade an Account</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Upgrade / Downgrade an Account</h1>
|
||||
<p className="text-[#64748b] text-sm">Change the hosting package assigned to an account.</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-5 space-y-4">
|
||||
<AccountPicker onSelect={handleSelect} selected={selected} />
|
||||
|
||||
{selected && (
|
||||
<div className="space-y-4 pt-2 border-t border-[#2a2d3e]">
|
||||
<div className="bg-[#0f1117] border border-[#2a2d3e] rounded-lg px-4 py-3">
|
||||
<p className="text-[#64748b] text-xs mb-1">Current Package</p>
|
||||
<p className="text-white font-medium">{selected.package?.name ?? 'No package assigned'}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[#94a3b8] text-sm font-medium">New Package</label>
|
||||
<select
|
||||
value={packageId}
|
||||
onChange={e => setPackageId(e.target.value)}
|
||||
className="w-full bg-[#0f1117] border border-[#2a2d3e] text-[#94a3b8] text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">— Select a package —</option>
|
||||
{packages.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} — {p.diskMb.toLocaleString()} MB disk / {p.bandwidthMb.toLocaleString()} MB BW
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !packageId}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ArrowUpDown size={16} /> {loading ? 'Applying...' : 'Apply Package Change'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className="text-emerald-400 text-sm bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3">{message}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ChevronLeft, Code2, Clock } from 'lucide-react'
|
||||
|
||||
export default function WebTemplate() {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[#64748b] hover:text-white transition-colors">
|
||||
<ChevronLeft size={14} /> Accounts
|
||||
</Link>
|
||||
<span className="text-[#2a2d3e]">/</span>
|
||||
<span className="text-white">Web Template Editor</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-semibold">Web Template Editor</h1>
|
||||
<p className="text-[#64748b] text-sm">Edit the default HTML templates served to new accounts.</p>
|
||||
</div>
|
||||
<div className="bg-[#1a1d27] border border-[#2a2d3e] rounded-xl p-8 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-14 h-14 rounded-full bg-orange-500/10 flex items-center justify-center">
|
||||
<Code2 size={28} className="text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<Clock size={14} className="text-orange-400" />
|
||||
<span className="text-orange-400 text-sm font-medium">Coming Soon</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold text-lg mb-1">Web Template Editor</p>
|
||||
<p className="text-[#64748b] text-sm max-w-sm">
|
||||
This feature will allow you to edit the default HTML templates and placeholder pages that are served to newly created hosting accounts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user