Initial YakPanel commit

This commit is contained in:
Niranjan
2026-04-07 02:04:22 +05:30
commit 2826d3e7f3
5359 changed files with 1390724 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YakPanel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,16 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:8888;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -0,0 +1,31 @@
{
"name": "yakpanel-server-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"lucide-react": "^0.303.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="12" fill="#2563eb"/>
<text x="50" y="68" font-size="36" font-weight="bold" fill="white" text-anchor="middle" font-family="sans-serif">YP</text>
</svg>

After

Width:  |  Height:  |  Size: 253 B

View File

@@ -0,0 +1,74 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components/Layout'
import { LoginPage } from './pages/LoginPage'
import { DashboardPage } from './pages/DashboardPage'
import { SitePage } from './pages/SitePage'
import { FilesPage } from './pages/FilesPage'
import { FtpPage } from './pages/FtpPage'
import { DatabasePage } from './pages/DatabasePage'
import { TerminalPage } from './pages/TerminalPage'
import { MonitorPage } from './pages/MonitorPage'
import { CrontabPage } from './pages/CrontabPage'
import { ConfigPage } from './pages/ConfigPage'
import { LogsPage } from './pages/LogsPage'
import { FirewallPage } from './pages/FirewallPage'
import { DomainsPage } from './pages/DomainsPage'
import { DockerPage } from './pages/DockerPage'
import { NodePage } from './pages/NodePage'
import { SoftPage } from './pages/SoftPage'
import { ServicesPage } from './pages/ServicesPage'
import { PluginsPage } from './pages/PluginsPage'
import { BackupPlansPage } from './pages/BackupPlansPage'
import { UsersPage } from './pages/UsersPage'
import { RemoteInstallPage } from './pages/RemoteInstallPage'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('token')
if (!token) return <Navigate to="/login" replace />
return <>{children}</>
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/install" element={<RemoteInstallPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="site" element={<SitePage />} />
<Route path="ftp" element={<FtpPage />} />
<Route path="database" element={<DatabasePage />} />
<Route path="docker" element={<DockerPage />} />
<Route path="control" element={<MonitorPage />} />
<Route path="firewall" element={<FirewallPage />} />
<Route path="files" element={<FilesPage />} />
<Route path="node" element={<NodePage />} />
<Route path="logs" element={<LogsPage />} />
<Route path="ssl_domain" element={<DomainsPage />} />
<Route path="xterm" element={<TerminalPage />} />
<Route path="crontab" element={<CrontabPage />} />
<Route path="soft" element={<SoftPage />} />
<Route path="config" element={<ConfigPage />} />
<Route path="services" element={<ServicesPage />} />
<Route path="plugins" element={<PluginsPage />} />
<Route path="backup-plans" element={<BackupPlansPage />} />
<Route path="users" element={<UsersPage />} />
</Route>
<Route path="/logout" element={<LogoutRedirect />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
function LogoutRedirect() {
localStorage.removeItem('token')
window.location.href = '/login'
return null
}

View File

@@ -0,0 +1,440 @@
const API_BASE = '/api/v1'
export async function apiRequest<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = localStorage.getItem('token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
if (res.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
throw new Error('Unauthorized')
}
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || err.message || `HTTP ${res.status}`)
}
return res.json()
}
export async function login(username: string, password: string) {
const form = new FormData()
form.append('username', username)
form.append('password', password)
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
body: form,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || `Login failed`)
}
const data = await res.json()
localStorage.setItem('token', data.access_token)
return data
}
export async function createSite(data: { name: string; domains: string[]; path?: string; ps?: string; php_version?: string; force_https?: boolean }) {
return apiRequest<{ status: boolean; msg: string; id?: number }>('/site/create', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function getSite(siteId: number) {
return apiRequest<{ id: number; name: string; path: string; status: number; ps: string; project_type: string; php_version: string; force_https: number; domains: string[] }>(
`/site/${siteId}`
)
}
export async function updateSite(siteId: number, data: { path?: string; domains?: string[]; ps?: string }) {
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function listSiteRedirects(siteId: number) {
return apiRequest<{ id: number; source: string; target: string; code: number }[]>(`/site/${siteId}/redirects`)
}
export async function addSiteRedirect(siteId: number, source: string, target: string, code = 301) {
return apiRequest<{ status: boolean; msg: string; id: number }>(`/site/${siteId}/redirects`, {
method: 'POST',
body: JSON.stringify({ source, target, code }),
})
}
export async function siteGitClone(siteId: number, url: string, branch = 'main') {
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}/git/clone`, {
method: 'POST',
body: JSON.stringify({ url, branch }),
})
}
export async function siteGitPull(siteId: number) {
return apiRequest<{ status: boolean; msg: string; output?: string }>(`/site/${siteId}/git/pull`, {
method: 'POST',
})
}
export async function deleteSiteRedirect(siteId: number, redirectId: number) {
return apiRequest<{ status: boolean }>(`/site/${siteId}/redirects/${redirectId}`, { method: 'DELETE' })
}
export async function setSiteStatus(siteId: number, status: boolean) {
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}/status`, {
method: 'POST',
body: JSON.stringify({ status: status ? 1 : 0 }),
})
}
export async function applyFirewallRules() {
return apiRequest<{ status: boolean; msg: string; count: number }>('/firewall/apply', { method: 'POST' })
}
export async function deleteSite(siteId: number) {
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}`, { method: 'DELETE' })
}
export async function createSiteBackup(siteId: number) {
return apiRequest<{ status: boolean; msg: string; filename: string }>(`/site/${siteId}/backup`, { method: 'POST' })
}
export async function listSiteBackups(siteId: number) {
return apiRequest<{ backups: { filename: string; size: number }[] }>(`/site/${siteId}/backups`)
}
export async function restoreSiteBackup(siteId: number, filename: string) {
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}/restore`, {
method: 'POST',
body: JSON.stringify({ filename }),
})
}
export async function downloadSiteBackup(siteId: number, filename: string): Promise<void> {
const token = localStorage.getItem('token')
const res = await fetch(`/api/v1/site/${siteId}/backups/download?file=${encodeURIComponent(filename)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export async function listFiles(path: string) {
return apiRequest<{ path: string; items: { name: string; is_dir: boolean; size: number }[] }>(
`/files/list?path=${encodeURIComponent(path)}`
)
}
export async function uploadFile(path: string, file: File) {
const form = new FormData()
form.append('path', path)
form.append('file', file)
const token = localStorage.getItem('token')
const res = await fetch('/api/v1/files/upload', {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || 'Upload failed')
}
return res.json()
}
export async function readFile(path: string) {
return apiRequest<{ path: string; content: string }>(`/files/read?path=${encodeURIComponent(path)}`)
}
export async function writeFile(path: string, content: string) {
return apiRequest<{ status: boolean }>('/files/write', {
method: 'POST',
body: JSON.stringify({ path, content }),
})
}
export async function mkdirFile(path: string, name: string) {
return apiRequest<{ status: boolean; msg: string }>('/files/mkdir', {
method: 'POST',
body: JSON.stringify({ path, name }),
})
}
export async function renameFile(path: string, oldName: string, newName: string) {
return apiRequest<{ status: boolean; msg: string }>('/files/rename', {
method: 'POST',
body: JSON.stringify({ path, old_name: oldName, new_name: newName }),
})
}
export async function deleteFile(path: string, name: string, isDir: boolean) {
return apiRequest<{ status: boolean; msg: string }>('/files/delete', {
method: 'POST',
body: JSON.stringify({ path, name, is_dir: isDir }),
})
}
export async function downloadFile(path: string): Promise<void> {
const token = localStorage.getItem('token')
const res = await fetch(`/api/v1/files/download?path=${encodeURIComponent(path)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = path.split('/').pop() || 'download'
a.click()
URL.revokeObjectURL(url)
}
export async function listLogs(path: string) {
return apiRequest<{ path: string; items: { name: string; is_dir: boolean; size: number }[] }>(
`/logs/list?path=${encodeURIComponent(path)}`
)
}
export async function readLog(path: string, tail = 1000) {
return apiRequest<{ path: string; content: string; total_lines: number }>(
`/logs/read?path=${encodeURIComponent(path)}&tail=${tail}`
)
}
export async function listSslDomains() {
return apiRequest<{ id: number; name: string; port: string; site_id: number; site_name: string; site_path: string }[]>(
'/ssl/domains'
)
}
export async function requestSslCert(domain: string, webroot: string, email: string) {
return apiRequest<{ status: boolean }>('/ssl/request', {
method: 'POST',
body: JSON.stringify({ domain, webroot, email }),
})
}
export async function listSslCertificates() {
return apiRequest<{ certificates: { name: string; path: string }[] }>('/ssl/certificates')
}
export async function nodeAddProcess(script: string, name?: string) {
return apiRequest<{ status: boolean; msg: string }>('/node/add', {
method: 'POST',
body: JSON.stringify({ script, name: name || '' }),
})
}
export async function listDockerContainers() {
return apiRequest<{ containers: { id: string; id_full: string; image: string; status: string; names: string; ports: string }[]; error?: string }>(
'/docker/containers'
)
}
export async function listDockerImages() {
return apiRequest<{ images: { repository: string; tag: string; id: string; size: string }[]; error?: string }>(
'/docker/images'
)
}
export async function dockerPull(image: string) {
return apiRequest<{ status: boolean; msg: string }>(`/docker/pull?image=${encodeURIComponent(image)}`, {
method: 'POST',
})
}
export async function dockerRun(image: string, name?: string, ports?: string, cmd?: string) {
return apiRequest<{ status: boolean; msg: string; id?: string }>('/docker/run', {
method: 'POST',
body: JSON.stringify({ image, name: name || '', ports: ports || '', cmd: cmd || '' }),
})
}
export async function dockerStart(containerId: string) {
return apiRequest<{ status: boolean }>(`/docker/${containerId}/start`, { method: 'POST' })
}
export async function dockerStop(containerId: string) {
return apiRequest<{ status: boolean }>(`/docker/${containerId}/stop`, { method: 'POST' })
}
export async function dockerRestart(containerId: string) {
return apiRequest<{ status: boolean }>(`/docker/${containerId}/restart`, { method: 'POST' })
}
export async function createDatabaseBackup(dbId: number) {
return apiRequest<{ status: boolean; msg: string; filename: string }>(`/database/${dbId}/backup`, {
method: 'POST',
})
}
export async function listDatabaseBackups(dbId: number) {
return apiRequest<{ backups: { filename: string; size: number }[] }>(`/database/${dbId}/backups`)
}
export async function restoreDatabaseBackup(dbId: number, filename: string) {
return apiRequest<{ status: boolean }>(`/database/${dbId}/restore`, {
method: 'POST',
body: JSON.stringify({ filename }),
})
}
export async function downloadDatabaseBackup(dbId: number, filename: string): Promise<void> {
const token = localStorage.getItem('token')
const res = await fetch(
`/api/v1/database/${dbId}/backups/download?file=${encodeURIComponent(filename)}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} }
)
if (!res.ok) throw new Error('Download failed')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export async function testEmail() {
return apiRequest<{ status: boolean; msg: string }>('/config/test-email', { method: 'POST' })
}
export async function listUsers() {
return apiRequest<{ id: number; username: string; email: string; is_active: boolean; is_superuser: boolean }[]>('/user/list')
}
export async function createUser(data: { username: string; password: string; email?: string }) {
return apiRequest<{ status: boolean; msg: string; id: number }>('/user/create', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteUser(userId: number) {
return apiRequest<{ status: boolean; msg: string }>(`/user/${userId}`, { method: 'DELETE' })
}
export async function toggleUserActive(userId: number) {
return apiRequest<{ status: boolean; msg: string; is_active: boolean }>(`/user/${userId}/toggle-active`, { method: 'PUT' })
}
export async function changePassword(oldPassword: string, newPassword: string) {
return apiRequest<{ message: string }>('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
})
}
export async function listServices() {
return apiRequest<{ services: { id: string; name: string; unit: string; status: string }[] }>('/service/list')
}
export async function serviceStart(id: string) {
return apiRequest<{ status: boolean }>(`/service/${id}/start`, { method: 'POST' })
}
export async function serviceStop(id: string) {
return apiRequest<{ status: boolean }>(`/service/${id}/stop`, { method: 'POST' })
}
export async function serviceRestart(id: string) {
return apiRequest<{ status: boolean }>(`/service/${id}/restart`, { method: 'POST' })
}
export async function updateFtpPassword(ftpId: number, password: string) {
return apiRequest<{ status: boolean }>(`/ftp/${ftpId}/password`, {
method: 'PUT',
body: JSON.stringify({ password }),
})
}
export async function updateDatabasePassword(dbId: number, password: string) {
return apiRequest<{ status: boolean }>(`/database/${dbId}/password`, {
method: 'PUT',
body: JSON.stringify({ password }),
})
}
export async function getDashboardStats() {
return apiRequest<{
site_count: number
ftp_count: number
database_count: number
system: {
cpu_percent: number
memory_percent: number
memory_used_mb: number
memory_total_mb: number
disk_percent: number
disk_used_gb: number
disk_total_gb: number
}
}>('/dashboard/stats')
}
export async function applyCrontab() {
return apiRequest<{ status: boolean; msg: string; count: number }>('/crontab/apply', { method: 'POST' })
}
export async function getMonitorProcesses(limit = 50) {
return apiRequest<{ processes: { pid: number; name: string; username: string; cpu_percent: number; memory_percent: number; status: string }[] }>(
`/monitor/processes?limit=${limit}`
)
}
export async function addPluginFromUrl(url: string) {
return apiRequest<{ status: boolean; msg: string; id: string }>('/plugin/add-from-url', {
method: 'POST',
body: JSON.stringify({ url }),
})
}
export async function deletePlugin(pluginId: string) {
return apiRequest<{ status: boolean; msg: string }>(`/plugin/${encodeURIComponent(pluginId)}`, { method: 'DELETE' })
}
export async function getMonitorNetwork() {
return apiRequest<{ bytes_sent: number; bytes_recv: number; bytes_sent_mb: number; bytes_recv_mb: number }>('/monitor/network')
}
export async function listBackupPlans() {
return apiRequest<{ id: number; name: string; plan_type: string; target_id: number; schedule: string; enabled: boolean }[]>('/backup/plans')
}
export async function createBackupPlan(data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
return apiRequest<{ status: boolean; msg: string; id: number }>('/backup/plans', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateBackupPlan(planId: number, data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
return apiRequest<{ status: boolean; msg: string }>(`/backup/plans/${planId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteBackupPlan(planId: number) {
return apiRequest<{ status: boolean; msg: string }>(`/backup/plans/${planId}`, { method: 'DELETE' })
}
export async function runScheduledBackups() {
return apiRequest<{ status: boolean; results: { plan: string; status: string; msg?: string }[] }>('/backup/run-scheduled', { method: 'POST' })
}

View File

@@ -0,0 +1,56 @@
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { Home, LogOut, Archive, Users } from 'lucide-react'
import { menuItems } from '../config/menu'
const iconMap: Record<string, React.ReactNode> = {
menuHome: <Home className="w-5 h-5" />,
menuBackupPlans: <Archive className="w-5 h-5" />,
menuUsers: <Users className="w-5 h-5" />,
menuLogout: <LogOut className="w-5 h-5" />,
}
export function Layout() {
const navigate = useNavigate()
return (
<div className="flex h-screen bg-gray-100 dark:bg-gray-900">
<aside className="w-56 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h1 className="text-xl font-bold text-gray-800 dark:text-white">YakPanel</h1>
</div>
<nav className="flex-1 overflow-y-auto p-2">
{menuItems
.filter((m) => m.id !== 'menuLogout')
.map((item) => (
<NavLink
key={item.id}
to={item.href}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors ${
isActive
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`
}
>
{iconMap[item.id] || <span className="w-5 h-5" />}
<span>{item.title}</span>
</NavLink>
))}
</nav>
<div className="p-2 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => navigate('/logout')}
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-gray-600 dark:text-gray-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600"
>
<LogOut className="w-5 h-5" />
<span>Log out</span>
</button>
</div>
</aside>
<main className="flex-1 overflow-auto p-6">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,29 @@
export interface MenuItem {
title: string
href: string
id: string
sort: number
}
export const menuItems: MenuItem[] = [
{ title: "Home", href: "/", id: "menuHome", sort: 1 },
{ title: "Website", href: "/site", id: "menuSite", sort: 2 },
{ title: "FTP", href: "/ftp", id: "menuFtp", sort: 3 },
{ title: "Databases", href: "/database", id: "menuDatabase", sort: 4 },
{ title: "Docker", href: "/docker", id: "menuDocker", sort: 5 },
{ title: "Monitor", href: "/control", id: "menuControl", sort: 6 },
{ title: "Security", href: "/firewall", id: "menuFirewall", sort: 7 },
{ title: "Files", href: "/files", id: "menuFiles", sort: 8 },
{ title: "Node", href: "/node", id: "menuNode", sort: 9 },
{ title: "Logs", href: "/logs", id: "menuLogs", sort: 10 },
{ title: "Domains", href: "/ssl_domain", id: "menuDomains", sort: 11 },
{ title: "Terminal", href: "/xterm", id: "menuXterm", sort: 12 },
{ title: "Cron", href: "/crontab", id: "menuCrontab", sort: 13 },
{ title: "App Store", href: "/soft", id: "menuSoft", sort: 14 },
{ title: "Services", href: "/services", id: "menuServices", sort: 15 },
{ title: "Plugins", href: "/plugins", id: "menuPlugins", sort: 16 },
{ title: "Backup Plans", href: "/backup-plans", id: "menuBackupPlans", sort: 17 },
{ title: "Users", href: "/users", id: "menuUsers", sort: 18 },
{ title: "Settings", href: "/config", id: "menuConfig", sort: 19 },
{ title: "Log out", href: "/logout", id: "menuLogout", sort: 20 },
]

View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
-webkit-font-smoothing: antialiased;
}

View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,395 @@
import { useEffect, useState } from 'react'
import {
apiRequest,
listBackupPlans,
createBackupPlan,
updateBackupPlan,
deleteBackupPlan,
runScheduledBackups,
} from '../api/client'
import { Plus, Trash2, Play, Pencil } from 'lucide-react'
interface BackupPlanRecord {
id: number
name: string
plan_type: string
target_id: number
schedule: string
enabled: boolean
}
interface SiteRecord {
id: number
name: string
}
interface DbRecord {
id: number
name: string
db_type: string
}
export function BackupPlansPage() {
const [plans, setPlans] = useState<BackupPlanRecord[]>([])
const [sites, setSites] = useState<SiteRecord[]>([])
const [databases, setDatabases] = useState<DbRecord[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [creating, setCreating] = useState(false)
const [editPlan, setEditPlan] = useState<BackupPlanRecord | null>(null)
const [editPlanType, setEditPlanType] = useState<'site' | 'database'>('site')
const [runLoading, setRunLoading] = useState(false)
const [runResults, setRunResults] = useState<{ plan: string; status: string; msg?: string }[] | null>(null)
const [createPlanType, setCreatePlanType] = useState<'site' | 'database'>('site')
const loadPlans = () => {
listBackupPlans()
.then(setPlans)
.catch((err) => setError(err.message))
}
useEffect(() => {
setLoading(true)
Promise.all([
listBackupPlans(),
apiRequest<SiteRecord[]>('/site/list'),
apiRequest<DbRecord[]>('/database/list'),
])
.then(([p, s, d]) => {
setPlans(p)
setSites(s)
setDatabases(d.filter((x) => ['MySQL', 'PostgreSQL', 'MongoDB'].includes(x.db_type)))
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
const plan_type = (form.elements.namedItem('plan_type') as HTMLSelectElement).value as 'site' | 'database'
const target_id = Number((form.elements.namedItem('target_id') as HTMLSelectElement).value)
const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim()
const enabled = (form.elements.namedItem('enabled') as HTMLInputElement).checked
if (!name || !schedule || !target_id) {
setError('Name, target and schedule are required')
return
}
setCreating(true)
createBackupPlan({ name, plan_type, target_id, schedule, enabled })
.then(() => {
setShowCreate(false)
form.reset()
loadPlans()
})
.catch((err) => setError(err.message))
.finally(() => setCreating(false))
}
const handleDelete = (id: number, name: string) => {
if (!confirm(`Delete backup plan "${name}"?`)) return
deleteBackupPlan(id)
.then(loadPlans)
.catch((err) => setError(err.message))
}
const handleEdit = (plan: BackupPlanRecord) => {
setEditPlan(plan)
setEditPlanType(plan.plan_type as 'site' | 'database')
}
const handleUpdate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!editPlan) return
const form = e.currentTarget
const name = (form.elements.namedItem('edit_name') as HTMLInputElement).value.trim()
const plan_type = editPlanType
const target_id = Number((form.elements.namedItem('edit_target_id') as HTMLSelectElement).value)
const schedule = (form.elements.namedItem('edit_schedule') as HTMLInputElement).value.trim()
const enabled = (form.elements.namedItem('edit_enabled') as HTMLInputElement).checked
if (!name || !schedule || !target_id) return
updateBackupPlan(editPlan.id, { name, plan_type: editPlanType, target_id, schedule, enabled })
.then(() => {
setEditPlan(null)
loadPlans()
})
.catch((err) => setError(err.message))
}
const handleRunScheduled = () => {
setRunLoading(true)
setRunResults(null)
runScheduledBackups()
.then((r) => setRunResults(r.results))
.catch((err) => setError(err.message))
.finally(() => setRunLoading(false))
}
const getTargetName = (plan: BackupPlanRecord) => {
if (plan.plan_type === 'site') {
const s = sites.find((x) => x.id === plan.target_id)
return s ? s.name : `#${plan.target_id}`
}
const d = databases.find((x) => x.id === plan.target_id)
return d ? d.name : `#${plan.target_id}`
}
if (loading) return <div className="text-gray-500">Loading...</div>
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Backup Plans</h1>
<div className="flex gap-2">
<button
onClick={handleRunScheduled}
disabled={runLoading}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
>
<Play className="w-4 h-4" />
{runLoading ? 'Running...' : 'Run Scheduled'}
</button>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
>
<Plus className="w-4 h-4" />
Add Plan
</button>
</div>
</div>
{runResults && runResults.length > 0 && (
<div className="mb-4 p-4 rounded bg-gray-100 dark:bg-gray-700">
<h3 className="font-medium mb-2">Last run results</h3>
<ul className="space-y-1 text-sm">
{runResults.map((r, i) => (
<li key={i}>
{r.plan}: {r.status === 'ok' ? '✓' : r.status === 'skipped' ? '⊘' : '✗'} {r.msg || ''}
</li>
))}
</ul>
</div>
)}
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Schedule automated backups. Add a cron entry (e.g. <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">0 * * * *</code> hourly) to call{' '}
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">POST /api/v1/backup/run-scheduled</code> with your auth token.
</p>
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Target</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Schedule</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Enabled</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{plans.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
No backup plans. Click "Add Plan" to create one.
</td>
</tr>
) : (
plans.map((p) => (
<tr key={p.id}>
<td className="px-4 py-2 text-gray-900 dark:text-white">{p.name}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{p.plan_type}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{getTargetName(p)}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400 font-mono text-sm">{p.schedule}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{p.enabled ? 'Yes' : 'No'}</td>
<td className="px-4 py-2 text-right">
<span className="flex gap-1 justify-end">
<button
onClick={() => handleEdit(p)}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
title="Edit"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(p.id, p.name)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{editPlan && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Edit Backup Plan</h2>
<form onSubmit={handleUpdate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input
name="edit_name"
type="text"
defaultValue={editPlan.name}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label>
<select
value={editPlanType}
onChange={(e) => setEditPlanType(e.target.value as 'site' | 'database')}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
>
<option value="site">Site</option>
<option value="database">Database</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Target</label>
<select
name="edit_target_id"
defaultValue={
editPlanType === editPlan.plan_type
? editPlan.target_id
: editPlanType === 'site'
? sites[0]?.id
: databases[0]?.id
}
key={editPlanType}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
>
{editPlanType === 'site'
? sites.map((s) => (
<option key={`s-${s.id}`} value={s.id}>
{s.name}
</option>
))
: databases.map((d) => (
<option key={`d-${d.id}`} value={d.id}>
{d.name} ({d.db_type})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schedule (cron)</label>
<input
name="edit_schedule"
type="text"
defaultValue={editPlan.schedule}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div className="flex items-center gap-2">
<input name="edit_enabled" type="checkbox" defaultChecked={editPlan.enabled} className="rounded" />
<label className="text-sm text-gray-700 dark:text-gray-300">Enabled</label>
</div>
<div className="flex gap-2 justify-end pt-2">
<button type="button" onClick={() => setEditPlan(null)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg">
Update
</button>
</div>
</form>
</div>
</div>
)}
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add Backup Plan</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input
name="name"
type="text"
placeholder="Daily site backup"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label>
<select
name="plan_type"
value={createPlanType}
onChange={(e) => setCreatePlanType(e.target.value as 'site' | 'database')}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
>
<option value="site">Site</option>
<option value="database">Database</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Target</label>
<select
name="target_id"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
>
<option value="">Select...</option>
{createPlanType === 'site'
? sites.map((s) => (
<option key={`s-${s.id}`} value={s.id}>
{s.name}
</option>
))
: databases.map((d) => (
<option key={`d-${d.id}`} value={d.id}>
{d.name} ({d.db_type})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schedule (cron)</label>
<input
name="schedule"
type="text"
placeholder="0 2 * * *"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
<p className="text-xs text-gray-500 mt-1">e.g. 0 2 * * * = daily at 2am, 0 */6 * * * = every 6 hours</p>
</div>
<div className="flex items-center gap-2">
<input name="enabled" type="checkbox" defaultChecked className="rounded" />
<label className="text-sm text-gray-700 dark:text-gray-300">Enabled</label>
</div>
<div className="flex gap-2 justify-end pt-2">
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit" disabled={creating} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,245 @@
import { useEffect, useState } from 'react'
import { apiRequest, changePassword, testEmail } from '../api/client'
import { Save, Key, Mail } from 'lucide-react'
interface PanelConfig {
panel_port: number
www_root: string
setup_path: string
webserver_type: string
mysql_root_set: boolean
app_name: string
app_version: string
}
export function ConfigPage() {
const [config, setConfig] = useState<PanelConfig | null>(null)
const [configKeys, setConfigKeys] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
const [pwSaved, setPwSaved] = useState(false)
const [pwError, setPwError] = useState('')
const [testEmailResult, setTestEmailResult] = useState<string | null>(null)
useEffect(() => {
Promise.all([
apiRequest<PanelConfig>('/config/panel'),
apiRequest<Record<string, string>>('/config/keys'),
])
.then(([cfg, keys]) => {
setConfig(cfg)
setConfigKeys(keys || {})
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
const handleChangePassword = (e: React.FormEvent) => {
e.preventDefault()
setPwSaved(false)
setPwError('')
const form = e.currentTarget
const oldPw = (form.elements.namedItem('old_password') as HTMLInputElement).value
const newPw = (form.elements.namedItem('new_password') as HTMLInputElement).value
const confirmPw = (form.elements.namedItem('confirm_password') as HTMLInputElement).value
if (!oldPw || !newPw) {
setPwError('All fields required')
return
}
if (newPw !== confirmPw) {
setPwError('New passwords do not match')
return
}
if (newPw.length < 6) {
setPwError('Password must be at least 6 characters')
return
}
changePassword(oldPw, newPw)
.then(() => {
setPwSaved(true)
form.reset()
})
.catch((err) => setPwError(err.message))
}
const handleTestEmail = () => {
setTestEmailResult(null)
testEmail()
.then(() => setTestEmailResult('Test email sent!'))
.catch((err) => setTestEmailResult(`Failed: ${err.message}`))
}
const handleSave = (e: React.FormEvent) => {
e.preventDefault()
setSaved(false)
const form = e.currentTarget
const port = (form.elements.namedItem('panel_port') as HTMLInputElement).value
const wwwRoot = (form.elements.namedItem('www_root') as HTMLInputElement).value
const setupPath = (form.elements.namedItem('setup_path') as HTMLInputElement).value
const webserver = (form.elements.namedItem('webserver_type') as HTMLSelectElement).value
const mysqlRoot = (form.elements.namedItem('mysql_root') as HTMLInputElement).value
const emailTo = (form.elements.namedItem('email_to') as HTMLInputElement)?.value || ''
const smtpServer = (form.elements.namedItem('smtp_server') as HTMLInputElement)?.value || ''
const smtpPort = (form.elements.namedItem('smtp_port') as HTMLInputElement)?.value || '587'
const smtpUser = (form.elements.namedItem('smtp_user') as HTMLInputElement)?.value || ''
const smtpPassword = (form.elements.namedItem('smtp_password') as HTMLInputElement)?.value || ''
const promises: Promise<unknown>[] = [
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'panel_port', value: port }) }),
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'www_root', value: wwwRoot }) }),
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'setup_path', value: setupPath }) }),
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'webserver_type', value: webserver }) }),
]
if (mysqlRoot) {
promises.push(apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'mysql_root', value: mysqlRoot }) }))
}
promises.push(
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'email_to', value: emailTo }) }),
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'smtp_server', value: smtpServer }) }),
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'smtp_port', value: smtpPort }) }),
apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'smtp_user', value: smtpUser }) }),
)
if (smtpPassword) {
promises.push(apiRequest('/config/set', { method: 'POST', body: JSON.stringify({ key: 'smtp_password', value: smtpPassword }) }))
}
Promise.all(promises)
.then(() => setSaved(true))
.catch((err) => setError(err.message))
}
if (loading) return <div className="text-gray-500">Loading...</div>
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
if (!config) return null
return (
<div>
<h1 className="text-2xl font-bold mb-6 text-gray-800 dark:text-white">Settings</h1>
<form onSubmit={handleSave} className="max-w-xl space-y-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">Panel</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Panel Port</label>
<input name="panel_port" type="number" defaultValue={config.panel_port} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">WWW Root</label>
<input name="www_root" type="text" defaultValue={config.www_root} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Setup Path</label>
<input name="setup_path" type="text" defaultValue={config.setup_path} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webserver</label>
<select name="webserver_type" defaultValue={config.webserver_type} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700">
<option value="nginx">Nginx</option>
<option value="apache">Apache</option>
<option value="openlitespeed">OpenLiteSpeed</option>
</select>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">Notifications (Email)</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email To</label>
<input name="email_to" type="email" defaultValue={configKeys.email_to} placeholder="admin@example.com" className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Server</label>
<input name="smtp_server" type="text" defaultValue={configKeys.smtp_server} placeholder="smtp.gmail.com" className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Port</label>
<input name="smtp_port" type="number" defaultValue={configKeys.smtp_port || '587'} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP User</label>
<input name="smtp_user" type="text" defaultValue={configKeys.smtp_user} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Password</label>
<input name="smtp_password" type="password" placeholder={configKeys.smtp_password ? '•••••••• (leave blank to keep)' : 'Optional'} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
</div>
<p className="text-xs text-gray-500">Used for panel alerts (e.g. backup completion, security warnings).</p>
<button
type="button"
onClick={handleTestEmail}
className="flex items-center gap-2 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg text-sm"
>
<Mail className="w-4 h-4" />
Send Test Email
</button>
{testEmailResult && (
<p className={`text-sm ${testEmailResult.startsWith('Failed') ? 'text-red-600' : 'text-green-600'}`}>
{testEmailResult}
</p>
)}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">MySQL</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Root Password</label>
<input
name="mysql_root"
type="password"
placeholder={config.mysql_root_set ? '•••••••• (leave blank to keep)' : 'Required for real DB creation'}
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
/>
<p className="mt-1 text-xs text-gray-500">Used to create/drop MySQL databases. Leave blank to keep current.</p>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<button type="submit" className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium">
<Save className="w-4 h-4" />
Save
</button>
{saved && <span className="text-green-600 dark:text-green-400 text-sm">Saved</span>}
</div>
</form>
<div className="mt-8 bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6 max-w-xl">
<h2 className="text-lg font-semibold mb-4 text-gray-800 dark:text-white">Change Password</h2>
<form onSubmit={handleChangePassword} className="space-y-4 max-w-md">
{pwError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
{pwError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
<input name="old_password" type="password" className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
<input name="new_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
<input name="confirm_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
</div>
<button type="submit" className="flex items-center gap-2 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium">
<Key className="w-4 h-4" />
Change Password
</button>
{pwSaved && <span className="text-green-600 dark:text-green-400 text-sm ml-2">Password changed</span>}
</form>
</div>
<div className="mt-8 p-4 bg-gray-100 dark:bg-gray-700/50 rounded-lg text-sm text-gray-600 dark:text-gray-400">
<p><strong>App:</strong> {config.app_name} v{config.app_version}</p>
<p className="mt-2">Note: Some settings require a panel restart to take effect.</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react'
import { apiRequest, applyCrontab } from '../api/client'
import { Plus, Trash2, Edit2, Zap } from 'lucide-react'
interface CronJob {
id: number
name: string
type: string
schedule: string
execstr: string
}
const SCHEDULE_PRESETS = [
{ label: 'Every minute', value: '* * * * *' },
{ label: 'Every 5 min', value: '*/5 * * * *' },
{ label: 'Every hour', value: '0 * * * *' },
{ label: 'Daily', value: '0 0 * * *' },
{ label: 'Weekly', value: '0 0 * * 0' },
]
export function CrontabPage() {
const [jobs, setJobs] = useState<CronJob[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<number | null>(null)
const [editJob, setEditJob] = useState<CronJob | null>(null)
const [saving, setSaving] = useState(false)
const [formError, setFormError] = useState('')
const [applying, setApplying] = useState(false)
const loadJobs = () => {
setLoading(true)
apiRequest<CronJob[]>('/crontab/list')
.then(setJobs)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
loadJobs()
}, [])
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim()
const execstr = (form.elements.namedItem('execstr') as HTMLTextAreaElement).value.trim()
if (!schedule || !execstr) {
setFormError('Schedule and command are required')
return
}
setSaving(true)
setFormError('')
const body = { name, type: 'shell', schedule, execstr }
const promise = editingId
? apiRequest(`/crontab/${editingId}`, { method: 'PUT', body: JSON.stringify(body) })
: apiRequest('/crontab/create', { method: 'POST', body: JSON.stringify(body) })
promise
.then(() => {
setShowForm(false)
setEditingId(null)
form.reset()
loadJobs()
})
.catch((err) => setFormError(err.message))
.finally(() => setSaving(false))
}
const handleEdit = (job: CronJob) => {
setEditingId(job.id)
setEditJob(job)
setShowForm(true)
}
const handleDelete = (id: number) => {
if (!confirm('Delete this cron job?')) return
apiRequest(`/crontab/${id}`, { method: 'DELETE' })
.then(loadJobs)
.catch((err) => setError(err.message))
}
const handleApply = () => {
setApplying(true)
applyCrontab()
.then(loadJobs)
.catch((err) => setError(err.message))
.finally(() => setApplying(false))
}
if (loading) return <div className="text-gray-500">Loading...</div>
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Cron</h1>
<div className="flex gap-2">
<button
onClick={handleApply}
disabled={applying || jobs.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg font-medium"
>
<Zap className="w-4 h-4" />
{applying ? 'Applying...' : 'Apply to System'}
</button>
<button
onClick={() => {
setEditingId(null)
setEditJob(null)
setShowForm(true)
}}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
>
<Plus className="w-4 h-4" />
Add Cron
</button>
</div>
</div>
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-lg">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">
{editingId ? 'Edit Cron Job' : 'Create Cron Job'}
</h2>
<form key={editingId ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
{formError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 text-sm">{formError}</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name (optional)</label>
<input id="cron-name" name="name" type="text" placeholder="My task" defaultValue={editJob?.name} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schedule (cron)</label>
<select
className="w-full mb-2 px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
onChange={(e) => {
const v = e.target.value
if (v) (document.getElementById('cron-schedule') as HTMLInputElement).value = v
}}
>
<option value="">Select preset...</option>
{SCHEDULE_PRESETS.map((p) => (
<option key={p.value} value={p.value}>{p.label} ({p.value})</option>
))}
</select>
<input id="cron-schedule" name="schedule" type="text" placeholder="* * * * *" defaultValue={editJob?.schedule} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Command</label>
<textarea id="cron-execstr" name="execstr" rows={3} placeholder="/usr/bin/php /www/wwwroot/script.php" defaultValue={editJob?.execstr} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
</div>
<div className="flex gap-2 justify-end pt-2">
<button type="button" onClick={() => { setShowForm(false); setEditingId(null); setEditJob(null) }} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg">Cancel</button>
<button type="submit" disabled={saving} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium">
{saving ? 'Saving...' : editingId ? 'Update' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200 text-sm">
Jobs are stored in the panel. Click &quot;Apply to System&quot; to sync them to the system crontab (root).
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Schedule</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Command</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{jobs.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">No cron jobs. Click "Add Cron" to create one.</td>
</tr>
) : (
jobs.map((j) => (
<tr key={j.id}>
<td className="px-4 py-2 text-gray-900 dark:text-white">{j.name || '-'}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400 font-mono text-sm">{j.schedule}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400 font-mono text-sm max-w-md truncate">{j.execstr}</td>
<td className="px-4 py-2 text-right flex gap-1 justify-end">
<button onClick={() => handleEdit(j)} className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded" title="Edit"><Edit2 className="w-4 h-4" /></button>
<button onClick={() => handleDelete(j.id)} className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded" title="Delete"><Trash2 className="w-4 h-4" /></button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react'
import { getDashboardStats } from '../api/client'
import { Server, Database, Folder, HardDrive, Cpu, MemoryStick } from 'lucide-react'
interface Stats {
site_count: number
ftp_count: number
database_count: number
system: {
cpu_percent: number
memory_percent: number
memory_used_mb: number
memory_total_mb: number
disk_percent: number
disk_used_gb: number
disk_total_gb: number
}
}
export function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null)
const [error, setError] = useState('')
useEffect(() => {
getDashboardStats()
.then(setStats)
.catch((err) => setError(err.message))
}, [])
if (error) {
return (
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700">
{error}
</div>
)
}
if (!stats) {
return <div className="text-gray-500">Loading...</div>
}
return (
<div>
<h1 className="text-2xl font-bold mb-6 text-gray-800 dark:text-white">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatCard
icon={<Server className="w-8 h-8" />}
title="Websites"
value={stats.site_count}
/>
<StatCard
icon={<Folder className="w-8 h-8" />}
title="FTP Accounts"
value={stats.ftp_count}
/>
<StatCard
icon={<Database className="w-8 h-8" />}
title="Databases"
value={stats.database_count}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
icon={<Cpu className="w-8 h-8" />}
title="CPU"
value={`${stats.system.cpu_percent}%`}
/>
<StatCard
icon={<MemoryStick className="w-8 h-8" />}
title="Memory"
value={`${stats.system.memory_percent}%`}
subtitle={`${stats.system.memory_used_mb} / ${stats.system.memory_total_mb} MB`}
/>
<StatCard
icon={<HardDrive className="w-8 h-8" />}
title="Disk"
value={`${stats.system.disk_percent}%`}
subtitle={`${stats.system.disk_used_gb} / ${stats.system.disk_total_gb} GB`}
/>
</div>
</div>
)
}
function StatCard({
icon,
title,
value,
subtitle,
}: {
icon: React.ReactNode
title: string
value: string | number
subtitle?: string
}) {
return (
<div className="p-4 bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
{icon}
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">{title}</p>
<p className="text-xl font-bold text-gray-800 dark:text-white">{value}</p>
{subtitle && (
<p className="text-xs text-gray-500 dark:text-gray-400">{subtitle}</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,388 @@
import { useEffect, useState } from 'react'
import {
apiRequest,
createDatabaseBackup,
listDatabaseBackups,
restoreDatabaseBackup,
downloadDatabaseBackup,
updateDatabasePassword,
} from '../api/client'
import { Plus, Trash2, Archive, Download, RotateCcw, Key } from 'lucide-react'
interface DbRecord {
id: number
name: string
username: string
db_type: string
ps: string
}
export function DatabasePage() {
const [databases, setDatabases] = useState<DbRecord[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [creating, setCreating] = useState(false)
const [creatingError, setCreatingError] = useState('')
const [backupDbId, setBackupDbId] = useState<number | null>(null)
const [backups, setBackups] = useState<{ filename: string; size: number }[]>([])
const [backupLoading, setBackupLoading] = useState(false)
const [changePwId, setChangePwId] = useState<number | null>(null)
const [pwError, setPwError] = useState('')
const loadDatabases = () => {
setLoading(true)
apiRequest<DbRecord[]>('/database/list')
.then(setDatabases)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
loadDatabases()
}, [])
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
const username = (form.elements.namedItem('username') as HTMLInputElement).value.trim()
const password = (form.elements.namedItem('password') as HTMLInputElement).value
const db_type = (form.elements.namedItem('db_type') as HTMLSelectElement).value
const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim()
if (!name || !username || !password) {
setCreatingError('Name, username and password are required')
return
}
setCreating(true)
setCreatingError('')
apiRequest<{ status: boolean; msg: string }>('/database/create', {
method: 'POST',
body: JSON.stringify({ name, username, password, db_type, ps }),
})
.then(() => {
setShowCreate(false)
form.reset()
loadDatabases()
})
.catch((err) => setCreatingError(err.message))
.finally(() => setCreating(false))
}
const handleDelete = (id: number, name: string) => {
if (!confirm(`Delete database "${name}"?`)) return
apiRequest<{ status: boolean }>(`/database/${id}`, { method: 'DELETE' })
.then(loadDatabases)
.catch((err) => setError(err.message))
}
const openBackupModal = (dbId: number) => {
setBackupDbId(dbId)
setBackups([])
listDatabaseBackups(dbId)
.then((data) => setBackups(data.backups || []))
.catch((err) => setError(err.message))
}
const handleCreateBackup = () => {
if (!backupDbId) return
setBackupLoading(true)
createDatabaseBackup(backupDbId)
.then(() => listDatabaseBackups(backupDbId!).then((d) => setBackups(d.backups || [])))
.catch((err) => setError(err.message))
.finally(() => setBackupLoading(false))
}
const handleRestore = (filename: string) => {
if (!backupDbId || !confirm(`Restore from ${filename}? This will overwrite the database.`)) return
setBackupLoading(true)
restoreDatabaseBackup(backupDbId, filename)
.then(() => setBackupDbId(null))
.catch((err) => setError(err.message))
.finally(() => setBackupLoading(false))
}
const handleDownloadBackup = (filename: string) => {
if (!backupDbId) return
downloadDatabaseBackup(backupDbId, filename).catch((err) => setError(err.message))
}
const handleChangePassword = (e: React.FormEvent, id: number) => {
e.preventDefault()
const form = e.currentTarget
const password = (form.elements.namedItem('new_password') as HTMLInputElement).value
const confirm = (form.elements.namedItem('confirm_password') as HTMLInputElement).value
if (!password || password.length < 6) {
setPwError('Password must be at least 6 characters')
return
}
if (password !== confirm) {
setPwError('Passwords do not match')
return
}
setPwError('')
updateDatabasePassword(id, password)
.then(() => setChangePwId(null))
.catch((err) => setPwError(err.message))
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
if (loading) return <div className="text-gray-500">Loading...</div>
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Databases</h1>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
>
<Plus className="w-4 h-4" />
Add Database
</button>
</div>
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Create Database</h2>
<form onSubmit={handleCreate} className="space-y-4">
{creatingError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
{creatingError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Database Name</label>
<input
name="name"
type="text"
placeholder="mydb"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
<input
name="username"
type="text"
placeholder="dbuser"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input
name="password"
type="password"
placeholder="••••••••"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label>
<select
name="db_type"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
>
<option value="MySQL">MySQL (full support)</option>
<option value="PostgreSQL">PostgreSQL (full support)</option>
<option value="MongoDB">MongoDB (full support)</option>
<option value="Redis">Redis (panel record only)</option>
</select>
<p className="mt-1 text-xs text-gray-500">MySQL, PostgreSQL, MongoDB: create, delete, backup/restore. MySQL/PostgreSQL/MongoDB: password change.</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Note (optional)</label>
<input
name="ps"
type="text"
placeholder="My database"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
/>
</div>
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
onClick={() => setShowCreate(false)}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Username</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Note</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{databases.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
No databases. Click "Add Database" to create one.
</td>
</tr>
) : (
databases.map((d) => (
<tr key={d.id}>
<td className="px-4 py-2 text-gray-900 dark:text-white">{d.name}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{d.username}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{d.db_type}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{d.ps || '-'}</td>
<td className="px-4 py-2 text-right">
<span className="flex gap-1 justify-end">
{(d.db_type === 'MySQL' || d.db_type === 'PostgreSQL' || d.db_type === 'MongoDB') && (
<button
onClick={() => openBackupModal(d.id)}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
title="Backup"
>
<Archive className="w-4 h-4" />
</button>
)}
{(d.db_type === 'MySQL' || d.db_type === 'PostgreSQL' || d.db_type === 'MongoDB') && (
<button
onClick={() => setChangePwId(d.id)}
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded"
title="Change password"
>
<Key className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDelete(d.id, d.name)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{backupDbId && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-lg">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Database Backup</h2>
<div className="mb-4">
<button
onClick={handleCreateBackup}
disabled={backupLoading}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
>
<Archive className="w-4 h-4" />
{backupLoading ? 'Creating...' : 'Create Backup'}
</button>
</div>
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Existing backups</h3>
{backups.length === 0 ? (
<p className="text-gray-500 text-sm">No backups yet</p>
) : (
<ul className="space-y-2 max-h-48 overflow-y-auto">
{backups.map((b) => (
<li key={b.filename} className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-mono text-gray-700 dark:text-gray-300 flex-1">
{b.filename}
</span>
<span className="text-gray-500 text-xs flex-shrink-0">{formatSize(b.size)}</span>
<span className="flex gap-1 flex-shrink-0">
<button
onClick={() => handleDownloadBackup(b.filename)}
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
title="Download"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => handleRestore(b.filename)}
disabled={backupLoading}
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
title="Restore"
>
<RotateCcw className="w-4 h-4" />
</button>
</span>
</li>
))}
</ul>
)}
</div>
<div className="flex justify-end">
<button
onClick={() => setBackupDbId(null)}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Close
</button>
</div>
</div>
</div>
)}
{changePwId && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Change Database Password</h2>
<form onSubmit={(e) => handleChangePassword(e, changePwId)} className="space-y-4">
{pwError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
{pwError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
<input name="new_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
<input name="confirm_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setChangePwId(null)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
Cancel
</button>
<button type="submit" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">
Update
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,335 @@
import { useEffect, useState } from 'react'
import { apiRequest, listDockerContainers, listDockerImages, dockerPull, dockerRun } from '../api/client'
import { Play, Square, RotateCw, Loader2, Plus, Download } from 'lucide-react'
interface Container {
id: string
id_full: string
image: string
status: string
names: string
ports: string
}
interface DockerImage {
repository: string
tag: string
id: string
size: string
}
export function DockerPage() {
const [containers, setContainers] = useState<Container[]>([])
const [images, setImages] = useState<DockerImage[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(null)
const [showRun, setShowRun] = useState(false)
const [runImage, setRunImage] = useState('')
const [runName, setRunName] = useState('')
const [runPorts, setRunPorts] = useState('')
const [running, setRunning] = useState(false)
const [pullImage, setPullImage] = useState('')
const [pulling, setPulling] = useState(false)
const load = () => {
setLoading(true)
Promise.all([
listDockerContainers(),
listDockerImages(),
])
.then(([contData, imgData]) => {
setContainers(contData.containers || [])
setImages(imgData.images || [])
setError(contData.error || imgData.error || '')
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [])
const handleStart = (id: string) => {
setActionId(id)
apiRequest<{ status: boolean }>(`/docker/${id}/start`, { method: 'POST' })
.then(load)
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
const handleStop = (id: string) => {
setActionId(id)
apiRequest<{ status: boolean }>(`/docker/${id}/stop`, { method: 'POST' })
.then(load)
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
const handleRestart = (id: string) => {
setActionId(id)
apiRequest<{ status: boolean }>(`/docker/${id}/restart`, { method: 'POST' })
.then(load)
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
const isRunning = (status: string) =>
status.toLowerCase().startsWith('up') || status.toLowerCase().includes('running')
const handleRun = (e: React.FormEvent) => {
e.preventDefault()
if (!runImage.trim()) return
setRunning(true)
dockerRun(runImage.trim(), runName.trim() || undefined, runPorts.trim() || undefined)
.then(() => {
setShowRun(false)
setRunImage('')
setRunName('')
setRunPorts('')
load()
})
.catch((err) => setError(err.message))
.finally(() => setRunning(false))
}
const handlePull = () => {
if (!pullImage.trim()) return
setPulling(true)
dockerPull(pullImage.trim())
.then(() => {
setPullImage('')
load()
})
.catch((err) => setError(err.message))
.finally(() => setPulling(false))
}
if (loading) return <div className="text-gray-500">Loading...</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Docker</h1>
<div className="flex gap-2">
<div className="flex gap-1">
<input
value={pullImage}
onChange={(e) => setPullImage(e.target.value)}
placeholder="nginx:latest"
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-sm w-40"
/>
<button
onClick={handlePull}
disabled={pulling || !pullImage.trim()}
className="flex items-center gap-1 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg disabled:opacity-50"
>
{pulling ? <Loader2 className="w-4 h-4 animate-spin" /> : <Download className="w-4 h-4" />}
Pull
</button>
</div>
<button
onClick={() => setShowRun(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
>
<Plus className="w-4 h-4" />
Run Container
</button>
<button
onClick={load}
className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg"
>
<RotateCw className="w-4 h-4" />
Refresh
</button>
</div>
</div>
{showRun && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Run Container</h2>
<form onSubmit={handleRun} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Image</label>
<input
value={runImage}
onChange={(e) => setRunImage(e.target.value)}
placeholder="nginx:latest"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name (optional)</label>
<input
value={runName}
onChange={(e) => setRunName(e.target.value)}
placeholder="my-nginx"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ports (optional, e.g. 80:80 or 8080:80)</label>
<input
value={runPorts}
onChange={(e) => setRunPorts(e.target.value)}
placeholder="80:80"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
/>
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowRun(false)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit" disabled={running} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
{running ? 'Starting...' : 'Run'}
</button>
</div>
</form>
</div>
</div>
)}
{error && (
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Container
</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Image
</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Status
</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Ports
</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{containers.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
No containers. Install Docker and run some containers.
</td>
</tr>
) : (
containers.map((c) => (
<tr key={c.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-4 py-2 font-mono text-gray-900 dark:text-white">{c.names || c.id}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{c.image}</td>
<td className="px-4 py-2">
<span
className={`text-sm ${
isRunning(c.status)
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{c.status}
</span>
</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400 text-sm max-w-xs truncate">
{c.ports || '-'}
</td>
<td className="px-4 py-2 text-right">
<span className="flex gap-1 justify-end">
{isRunning(c.status) ? (
<>
<button
onClick={() => handleRestart(c.id_full)}
disabled={actionId === c.id_full}
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
title="Restart"
>
{actionId === c.id_full ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RotateCw className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleStop(c.id_full)}
disabled={actionId === c.id_full}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50"
title="Stop"
>
<Square className="w-4 h-4" />
</button>
</>
) : (
<button
onClick={() => handleStart(c.id_full)}
disabled={actionId === c.id_full}
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded disabled:opacity-50"
title="Start"
>
{actionId === c.id_full ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</button>
)}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="mt-8">
<h2 className="text-lg font-bold mb-3 text-gray-800 dark:text-white">Images</h2>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{images.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500">No images. Pull one above.</div>
) : (
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Repository</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Tag</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Size</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{images.map((img) => (
<tr key={img.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-4 py-2 font-mono text-gray-900 dark:text-white">{img.repository}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{img.tag}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{img.size}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => { setRunImage(`${img.repository}:${img.tag}`); setShowRun(true) }}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
title="Run"
>
<Play className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react'
import { apiRequest } from '../api/client'
import { Shield, Loader2 } from 'lucide-react'
interface Domain {
id: number
name: string
port: string
site_id: number
site_name: string
site_path: string
}
interface Certificate {
name: string
path: string
}
export function DomainsPage() {
const [domains, setDomains] = useState<Domain[]>([])
const [certificates, setCertificates] = useState<Certificate[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [requesting, setRequesting] = useState<string | null>(null)
const [requestDomain, setRequestDomain] = useState<Domain | null>(null)
const [requestEmail, setRequestEmail] = useState('')
const load = () => {
setLoading(true)
Promise.all([
apiRequest<Domain[]>('/ssl/domains'),
apiRequest<{ certificates: Certificate[] }>('/ssl/certificates'),
])
.then(([d, c]) => {
setDomains(d)
setCertificates(c.certificates || [])
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [])
const handleRequestCert = (e: React.FormEvent) => {
e.preventDefault()
if (!requestDomain) return
setRequesting(requestDomain.name)
apiRequest<{ status: boolean }>('/ssl/request', {
method: 'POST',
body: JSON.stringify({
domain: requestDomain.name,
webroot: requestDomain.site_path,
email: requestEmail,
}),
})
.then(() => {
setRequestDomain(null)
load()
})
.catch((err) => setError(err.message))
.finally(() => setRequesting(null))
}
const hasCert = (domain: string) =>
certificates.some((c) => c.name === domain || c.name.startsWith(domain + ' '))
if (loading) return <div className="text-gray-500">Loading...</div>
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
return (
<div>
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Domains & SSL</h1>
<div className="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-800 dark:text-amber-200">
<p>Request Let&apos;s Encrypt certificates for your site domains. Requires certbot and nginx configured for the domain.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<h2 className="px-4 py-2 border-b dark:border-gray-700 font-medium text-gray-800 dark:text-white">
Domains (from sites)
</h2>
<div className="divide-y divide-gray-200 dark:divide-gray-700 max-h-80 overflow-y-auto">
{domains.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500">No domains. Add a site first.</div>
) : (
domains.map((d) => (
<div
key={d.id}
className="flex items-center justify-between gap-2 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30"
>
<div>
<span className="font-mono text-gray-900 dark:text-white">{d.name}</span>
{d.port !== '80' && (
<span className="ml-2 text-gray-500 text-sm">:{d.port}</span>
)}
<span className="ml-2 text-gray-500 text-sm">({d.site_name})</span>
</div>
<div className="flex items-center gap-2">
{hasCert(d.name) ? (
<span className="text-green-600 dark:text-green-400 text-sm flex items-center gap-1">
<Shield className="w-4 h-4" /> Cert
</span>
) : (
<button
onClick={() => {
setRequestDomain(d)
setRequestEmail('')
}}
disabled={!!requesting}
className="text-sm px-2 py-1 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/50 disabled:opacity-50"
>
{requesting === d.name ? (
<Loader2 className="w-4 h-4 animate-spin inline" />
) : (
'Request SSL'
)}
</button>
)}
</div>
</div>
))
)}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<h2 className="px-4 py-2 border-b dark:border-gray-700 font-medium text-gray-800 dark:text-white">
Certificates
</h2>
<div className="divide-y divide-gray-200 dark:divide-gray-700 max-h-80 overflow-y-auto">
{certificates.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500">No certificates yet</div>
) : (
certificates.map((c) => (
<div
key={c.name}
className="flex items-center gap-2 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30"
>
<Shield className="w-4 h-4 text-green-600 flex-shrink-0" />
<span className="font-mono text-gray-900 dark:text-white">{c.name}</span>
</div>
))
)}
</div>
</div>
</div>
{requestDomain && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">
Request SSL for {requestDomain.name}
</h2>
<form onSubmit={handleRequestCert} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Domain
</label>
<input
type="text"
value={requestDomain.name}
readOnly
className="w-full px-4 py-2 border rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Webroot (site path)
</label>
<input
type="text"
value={requestDomain.site_path}
readOnly
className="w-full px-4 py-2 border rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email (for Let&apos;s Encrypt)
</label>
<input
type="email"
value={requestEmail}
onChange={(e) => setRequestEmail(e.target.value)}
placeholder="admin@example.com"
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
onClick={() => setRequestDomain(null)}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={!!requesting}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
>
{requesting ? 'Requesting...' : 'Request'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,354 @@
import { useEffect, useState, useRef } from 'react'
import { listFiles, downloadFile, uploadFile, readFile, writeFile, mkdirFile, renameFile, deleteFile } from '../api/client'
import { Folder, File, ArrowLeft, Download, Loader2, Upload, Edit2, FolderPlus, Trash2, Pencil, Check } from 'lucide-react'
interface FileItem {
name: string
is_dir: boolean
size: number
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
const TEXT_EXT = ['.txt', '.html', '.htm', '.css', '.js', '.json', '.xml', '.md', '.py', '.php', '.sh', '.conf', '.env']
export function FilesPage() {
const [path, setPath] = useState('/')
const [items, setItems] = useState<FileItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [downloading, setDownloading] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [editingFile, setEditingFile] = useState<string | null>(null)
const [editContent, setEditContent] = useState('')
const [savingEdit, setSavingEdit] = useState(false)
const [showMkdir, setShowMkdir] = useState(false)
const [mkdirName, setMkdirName] = useState('')
const [renaming, setRenaming] = useState<FileItem | null>(null)
const [renameValue, setRenameValue] = useState('')
const fileInputRef = useRef<HTMLInputElement>(null)
const loadDir = (p: string) => {
setLoading(true)
setError('')
listFiles(p)
.then((data) => {
setPath(data.path)
setItems(data.items.sort((a, b) => (a.is_dir === b.is_dir ? 0 : a.is_dir ? -1 : 1)))
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
loadDir(path)
}, [])
const handleNavigate = (item: FileItem) => {
if (item.is_dir) {
const newPath = path.endsWith('/') ? path + item.name : path + '/' + item.name
loadDir(newPath)
}
}
const handleBack = () => {
const parts = path.replace(/\/$/, '').split('/').filter(Boolean)
if (parts.length <= 1) return
parts.pop()
const newPath = parts.length === 0 ? '/' : '/' + parts.join('/')
loadDir(newPath)
}
const handleDownload = (item: FileItem) => {
if (item.is_dir) return
const fullPath = path.endsWith('/') ? path + item.name : path + '/' + item.name
setDownloading(item.name)
downloadFile(fullPath)
.catch((err) => setError(err.message))
.finally(() => setDownloading(null))
}
const handleUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setError('')
uploadFile(path, file)
.then(() => loadDir(path))
.catch((err) => setError(err.message))
.finally(() => {
setUploading(false)
e.target.value = ''
})
}
const handleEdit = (item: FileItem) => {
const fullPath = path.endsWith('/') ? path + item.name : path + '/' + item.name
readFile(fullPath)
.then((data) => {
setEditingFile(fullPath)
setEditContent(typeof data.content === 'string' ? data.content : String(data.content))
})
.catch((err) => setError(err.message))
}
const handleSaveEdit = () => {
if (!editingFile) return
setSavingEdit(true)
writeFile(editingFile, editContent)
.then(() => {
setEditingFile(null)
loadDir(path)
})
.catch((err) => setError(err.message))
.finally(() => setSavingEdit(false))
}
const canEdit = (name: string) => TEXT_EXT.some((ext) => name.toLowerCase().endsWith(ext))
const handleMkdir = (e: React.FormEvent) => {
e.preventDefault()
const name = mkdirName.trim()
if (!name) return
mkdirFile(path, name)
.then(() => {
setShowMkdir(false)
setMkdirName('')
loadDir(path)
})
.catch((err) => setError(err.message))
}
const handleRename = () => {
if (!renaming || !renameValue.trim()) return
const newName = renameValue.trim()
if (newName === renaming.name) {
setRenaming(null)
return
}
renameFile(path, renaming.name, newName)
.then(() => {
setRenaming(null)
setRenameValue('')
loadDir(path)
})
.catch((err) => setError(err.message))
}
const handleDelete = (item: FileItem) => {
if (!confirm(`Delete ${item.is_dir ? 'folder' : 'file'} "${item.name}"?`)) return
deleteFile(path, item.name, item.is_dir)
.then(() => loadDir(path))
.catch((err) => setError(err.message))
}
const breadcrumbs = path.split('/').filter(Boolean)
const canGoBack = breadcrumbs.length > 0
return (
<div>
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Files</h1>
<div className="mb-4 flex items-center gap-2 flex-wrap">
<button
onClick={handleBack}
disabled={!canGoBack}
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowLeft className="w-4 h-4" />
Back
</button>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleUpload}
/>
<button
onClick={() => setShowMkdir(true)}
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-green-600 hover:bg-green-700 text-white"
>
<FolderPlus className="w-4 h-4" />
New Folder
</button>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50"
>
{uploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
Upload
</button>
<div className="flex items-center gap-1 px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm">
<span className="text-gray-500">Path:</span>
<span className="text-gray-800 dark:text-white font-mono">{path}</span>
</div>
</div>
{showMkdir && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">New Folder</h2>
<form onSubmit={handleMkdir} className="space-y-4">
<input
value={mkdirName}
onChange={(e) => setMkdirName(e.target.value)}
placeholder="Folder name"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
autoFocus
/>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => { setShowMkdir(false); setMkdirName('') }} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit" className="px-4 py-2 bg-green-600 text-white rounded-lg">Create</button>
</div>
</form>
</div>
</div>
)}
{editingFile && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="p-4 border-b dark:border-gray-700 flex justify-between items-center">
<span className="font-mono text-sm text-gray-600 dark:text-gray-400">{editingFile}</span>
<div className="flex gap-2">
<button onClick={() => setEditingFile(null)} className="px-3 py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700">Cancel</button>
<button onClick={handleSaveEdit} disabled={savingEdit} className="px-3 py-1 bg-blue-600 text-white rounded disabled:opacity-50">
{savingEdit ? 'Saving...' : 'Save'}
</button>
</div>
</div>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="flex-1 p-4 font-mono text-sm bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-white resize-none min-h-[400px]"
spellCheck={false}
/>
</div>
</div>
)}
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
{error}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{loading ? (
<div className="p-8 flex justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
) : (
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Size</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{items.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-gray-500">
Empty directory
</td>
</tr>
) : (
items.map((item) => (
<tr
key={item.name}
className="hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<td className="px-4 py-2">
<button
onClick={() => handleNavigate(item)}
className="flex items-center gap-2 text-left w-full hover:text-blue-600 dark:hover:text-blue-400"
>
{item.is_dir ? (
<Folder className="w-5 h-5 text-amber-500" />
) : (
<File className="w-5 h-5 text-gray-400" />
)}
<span className="text-gray-900 dark:text-white">{item.name}</span>
</button>
</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">
{item.is_dir ? '-' : formatSize(item.size)}
</td>
<td className="px-4 py-2 text-right">
<span className="flex gap-1 justify-end">
{renaming?.name === item.name ? (
<span className="flex gap-1 items-center">
<input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
className="px-2 py-1 text-sm border rounded w-32"
autoFocus
/>
<button onClick={handleRename} className="p-1.5 text-green-600 rounded" title="Save">
<Check className="w-4 h-4" />
</button>
<button onClick={() => { setRenaming(null); setRenameValue('') }} className="p-1.5 text-gray-500 rounded">Cancel</button>
</span>
) : (
<>
<button
onClick={() => { setRenaming(item); setRenameValue(item.name) }}
className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Rename"
>
<Pencil className="w-4 h-4" />
</button>
{!item.is_dir && canEdit(item.name) && (
<button
onClick={() => handleEdit(item)}
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded"
title="Edit"
>
<Edit2 className="w-4 h-4" />
</button>
)}
{!item.is_dir && (
<button
onClick={() => handleDownload(item)}
disabled={downloading === item.name}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded disabled:opacity-50"
title="Download"
>
{downloading === item.name ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
</button>
)}
<button
onClick={() => handleDelete(item)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,219 @@
import { useEffect, useState } from 'react'
import { apiRequest, applyFirewallRules } from '../api/client'
import { Plus, Trash2, Zap } from 'lucide-react'
interface FirewallRule {
id: number
port: string
protocol: string
action: string
ps: string
}
export function FirewallPage() {
const [rules, setRules] = useState<FirewallRule[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [creating, setCreating] = useState(false)
const [creatingError, setCreatingError] = useState('')
const [applying, setApplying] = useState(false)
const loadRules = () => {
setLoading(true)
apiRequest<FirewallRule[]>('/firewall/list')
.then(setRules)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
loadRules()
}, [])
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const port = (form.elements.namedItem('port') as HTMLInputElement).value.trim()
const protocol = (form.elements.namedItem('protocol') as HTMLSelectElement).value
const action = (form.elements.namedItem('action') as HTMLSelectElement).value
const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim()
if (!port) {
setCreatingError('Port is required')
return
}
setCreating(true)
setCreatingError('')
apiRequest<{ status: boolean; msg: string }>('/firewall/create', {
method: 'POST',
body: JSON.stringify({ port, protocol, action, ps }),
})
.then(() => {
setShowCreate(false)
form.reset()
loadRules()
})
.catch((err) => setCreatingError(err.message))
.finally(() => setCreating(false))
}
const handleDelete = (id: number, port: string) => {
if (!confirm(`Delete rule for port ${port}?`)) return
apiRequest<{ status: boolean }>(`/firewall/${id}`, { method: 'DELETE' })
.then(loadRules)
.catch((err) => setError(err.message))
}
const handleApply = () => {
setApplying(true)
applyFirewallRules()
.then(() => loadRules())
.catch((err) => setError(err.message))
.finally(() => setApplying(false))
}
if (loading) return <div className="text-gray-500">Loading...</div>
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Security / Firewall</h1>
<div className="flex gap-2">
<button
onClick={handleApply}
disabled={applying || rules.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg font-medium"
>
<Zap className="w-4 h-4" />
{applying ? 'Applying...' : 'Apply to UFW'}
</button>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
>
<Plus className="w-4 h-4" />
Add Rule
</button>
</div>
</div>
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200 text-sm">
Rules are stored in the panel. Click &quot;Apply to UFW&quot; to run <code className="font-mono">ufw allow/deny</code> for each rule.
</div>
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add Firewall Rule</h2>
<form onSubmit={handleCreate} className="space-y-4">
{creatingError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
{creatingError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Port</label>
<input
name="port"
type="text"
placeholder="80 or 80-90 or 80,443"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Protocol</label>
<select
name="protocol"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Action</label>
<select
name="action"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
>
<option value="accept">Accept</option>
<option value="drop">Drop</option>
<option value="reject">Reject</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Note (optional)</label>
<input
name="ps"
type="text"
placeholder="HTTP"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
/>
</div>
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
onClick={() => setShowCreate(false)}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
>
{creating ? 'Adding...' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Port</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Protocol</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Action</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Note</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{rules.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
No rules. Click "Add Rule" to create one.
</td>
</tr>
) : (
rules.map((r) => (
<tr key={r.id}>
<td className="px-4 py-2 text-gray-900 dark:text-white font-mono">{r.port}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{r.protocol}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{r.action}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{r.ps || '-'}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => handleDelete(r.id, r.port)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,257 @@
import { useEffect, useState } from 'react'
import { apiRequest, updateFtpPassword } from '../api/client'
import { Plus, Trash2, Key } from 'lucide-react'
interface FtpAccount {
id: number
name: string
path: string
ps: string
}
export function FtpPage() {
const [accounts, setAccounts] = useState<FtpAccount[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [creating, setCreating] = useState(false)
const [creatingError, setCreatingError] = useState('')
const [changePwId, setChangePwId] = useState<number | null>(null)
const [pwError, setPwError] = useState('')
const loadAccounts = () => {
setLoading(true)
apiRequest<FtpAccount[]>('/ftp/list')
.then(setAccounts)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
loadAccounts()
}, [])
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
const password = (form.elements.namedItem('password') as HTMLInputElement).value
const path = (form.elements.namedItem('path') as HTMLInputElement).value.trim()
const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim()
if (!name || !password || !path) {
setCreatingError('Name, password and path are required')
return
}
setCreating(true)
setCreatingError('')
apiRequest<{ status: boolean; msg: string }>('/ftp/create', {
method: 'POST',
body: JSON.stringify({ name, password, path, ps }),
})
.then(() => {
setShowCreate(false)
form.reset()
loadAccounts()
})
.catch((err) => setCreatingError(err.message))
.finally(() => setCreating(false))
}
const handleChangePassword = (e: React.FormEvent, id: number) => {
e.preventDefault()
const form = e.currentTarget
const password = (form.elements.namedItem('new_password') as HTMLInputElement).value
const confirm = (form.elements.namedItem('confirm_password') as HTMLInputElement).value
if (!password || password.length < 6) {
setPwError('Password must be at least 6 characters')
return
}
if (password !== confirm) {
setPwError('Passwords do not match')
return
}
setPwError('')
updateFtpPassword(id, password)
.then(() => setChangePwId(null))
.catch((err) => setPwError(err.message))
}
const handleDelete = (id: number, name: string) => {
if (!confirm(`Delete FTP account "${name}"?`)) return
apiRequest<{ status: boolean }>(`/ftp/${id}`, { method: 'DELETE' })
.then(loadAccounts)
.catch((err) => setError(err.message))
}
if (loading) return <div className="text-gray-500">Loading...</div>
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">FTP</h1>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
>
<Plus className="w-4 h-4" />
Add FTP
</button>
</div>
<div className="mb-4 p-3 rounded-lg bg-gray-100 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
FTP accounts use Pure-FTPd (pure-pw). Path must be under www root. Install: <code className="font-mono">apt install pure-ftpd pure-ftpd-common</code>
</div>
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Create FTP Account</h2>
<form onSubmit={handleCreate} className="space-y-4">
{creatingError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
{creatingError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
<input
name="name"
type="text"
placeholder="ftpuser"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input
name="password"
type="password"
placeholder="••••••••"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Path</label>
<input
name="path"
type="text"
placeholder="/www/wwwroot"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Note (optional)</label>
<input
name="ps"
type="text"
placeholder="My FTP"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
/>
</div>
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
onClick={() => setShowCreate(false)}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Path</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Note</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{accounts.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
No FTP accounts. Click "Add FTP" to create one.
</td>
</tr>
) : (
accounts.map((a) => (
<tr key={a.id}>
<td className="px-4 py-2 text-gray-900 dark:text-white">{a.name}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{a.path}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{a.ps || '-'}</td>
<td className="px-4 py-2 text-right">
<span className="flex gap-1 justify-end">
<button
onClick={() => setChangePwId(a.id)}
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded"
title="Change password"
>
<Key className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(a.id, a.name)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{changePwId && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Change FTP Password</h2>
<form onSubmit={(e) => handleChangePassword(e, changePwId)} className="space-y-4">
{pwError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
{pwError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
<input name="new_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
<input name="confirm_password" type="password" minLength={6} className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700" required />
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setChangePwId(null)} className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
Cancel
</button>
<button type="submit" className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">
Update
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { login } from '../api/client'
export function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
await login(username, password)
navigate('/')
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="w-full max-w-md p-8 bg-white dark:bg-gray-800 rounded-xl shadow-lg">
<h1 className="text-2xl font-bold text-center mb-6 text-gray-800 dark:text-white">
YakPanel
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-500">
Default: admin / admin
</p>
<p className="mt-2 text-center text-sm">
<a href="/install" className="text-blue-600 hover:underline dark:text-blue-400">
Remote SSH install (optional)
</a>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react'
import { listLogs, readLog } from '../api/client'
import { Folder, File, ArrowLeft, Loader2, RefreshCw } from 'lucide-react'
interface LogItem {
name: string
is_dir: boolean
size: number
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
export function LogsPage() {
const [path, setPath] = useState('/')
const [items, setItems] = useState<LogItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [viewingFile, setViewingFile] = useState<string | null>(null)
const [fileContent, setFileContent] = useState('')
const [fileLoading, setFileLoading] = useState(false)
const [tailLines, setTailLines] = useState(500)
const loadDir = (p: string) => {
setLoading(true)
setError('')
listLogs(p)
.then((data) => {
setPath(data.path)
setItems(data.items.sort((a, b) => (a.is_dir === b.is_dir ? 0 : a.is_dir ? -1 : 1)))
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
loadDir(path)
}, [])
useEffect(() => {
if (viewingFile) {
setFileLoading(true)
readLog(viewingFile, tailLines)
.then((data) => setFileContent(data.content))
.catch((err) => setError(err.message))
.finally(() => setFileLoading(false))
}
}, [viewingFile, tailLines])
const handleNavigate = (item: LogItem) => {
if (item.is_dir) {
const newPath = path === '/' ? '/' + item.name : path + '/' + item.name
loadDir(newPath)
} else {
setViewingFile(path === '/' ? item.name : path + '/' + item.name)
}
}
const handleBack = () => {
const parts = path.replace(/\/$/, '').split('/').filter(Boolean)
if (parts.length <= 1) return
parts.pop()
const newPath = parts.length === 0 ? '/' : '/' + parts.join('/')
loadDir(newPath)
}
const handleRefreshFile = () => {
if (!viewingFile) return
setFileLoading(true)
readLog(viewingFile, tailLines)
.then((data) => setFileContent(data.content))
.catch((err) => setError(err.message))
.finally(() => setFileLoading(false))
}
const breadcrumbs = path.split('/').filter(Boolean)
const canGoBack = breadcrumbs.length > 0
return (
<div>
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Logs</h1>
<div className="mb-4 flex items-center gap-2 flex-wrap">
<button
onClick={handleBack}
disabled={!canGoBack}
className="flex items-center gap-1 px-3 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowLeft className="w-4 h-4" />
Back
</button>
<div className="flex items-center gap-1 px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm">
<span className="text-gray-500">Path:</span>
<span className="text-gray-800 dark:text-white font-mono">{path || '/'}</span>
</div>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="px-4 py-2 border-b dark:border-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300">
Log files
</div>
{loading ? (
<div className="p-8 flex justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
) : (
<div className="divide-y divide-gray-200 dark:divide-gray-700 max-h-[500px] overflow-y-auto">
{items.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500">Empty directory</div>
) : (
items.map((item) => (
<button
key={item.name}
onClick={() => handleNavigate(item)}
className="flex items-center gap-2 w-full px-4 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
{item.is_dir ? (
<Folder className="w-5 h-5 text-amber-500 flex-shrink-0" />
) : (
<File className="w-5 h-5 text-gray-400 flex-shrink-0" />
)}
<span className="text-gray-900 dark:text-white truncate">{item.name}</span>
{!item.is_dir && (
<span className="ml-auto text-gray-500 text-sm flex-shrink-0">
{formatSize(item.size)}
</span>
)}
</button>
))
)}
</div>
)}
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden flex flex-col">
<div className="px-4 py-2 border-b dark:border-gray-700 flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
{viewingFile || 'Select a log file'}
</span>
{viewingFile && (
<div className="flex items-center gap-2 flex-shrink-0">
<label className="text-xs text-gray-500">Lines:</label>
<select
value={tailLines}
onChange={(e) => setTailLines(Number(e.target.value))}
className="text-sm border rounded px-2 py-1 bg-white dark:bg-gray-700"
>
<option value={100}>100</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
<option value={5000}>5000</option>
<option value={10000}>10000</option>
</select>
<button
onClick={handleRefreshFile}
disabled={fileLoading}
className="p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${fileLoading ? 'animate-spin' : ''}`} />
</button>
</div>
)}
</div>
<div className="flex-1 overflow-auto p-4 min-h-[400px]">
{!viewingFile ? (
<div className="text-gray-500 text-sm">Click a log file to view</div>
) : fileLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
) : (
<pre className="font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all">
{fileContent || '(empty)'}
</pre>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react'
import { apiRequest, getMonitorProcesses, getMonitorNetwork } from '../api/client'
import { Cpu, HardDrive, MemoryStick, Activity, Network } from 'lucide-react'
interface SystemStats {
cpu_percent: number
memory_percent: number
memory_used_mb: number
memory_total_mb: number
disk_percent: number
disk_used_gb: number
disk_total_gb: number
}
interface ProcessInfo {
pid: number
name: string
username: string
cpu_percent: number
memory_percent: number
status: string
}
interface NetworkStats {
bytes_sent_mb: number
bytes_recv_mb: number
}
export function MonitorPage() {
const [stats, setStats] = useState<SystemStats | null>(null)
const [processes, setProcesses] = useState<ProcessInfo[]>([])
const [network, setNetwork] = useState<NetworkStats | null>(null)
const [error, setError] = useState('')
useEffect(() => {
const fetchStats = () => {
apiRequest<SystemStats>('/monitor/system')
.then(setStats)
.catch((err) => setError(err.message))
}
const fetchProcesses = () => {
getMonitorProcesses(50)
.then((d) => setProcesses(d.processes))
.catch(() => setProcesses([]))
}
const fetchNetwork = () => {
getMonitorNetwork()
.then(setNetwork)
.catch(() => setNetwork(null))
}
fetchStats()
fetchProcesses()
fetchNetwork()
const interval = setInterval(() => {
fetchStats()
fetchProcesses()
fetchNetwork()
}, 3000)
return () => clearInterval(interval)
}, [])
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
if (!stats) return <div className="text-gray-500">Loading...</div>
return (
<div>
<h1 className="text-2xl font-bold mb-6 text-gray-800 dark:text-white">Monitor</h1>
<p className="text-sm text-gray-500 mb-4">Refreshes every 3 seconds</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard
icon={<Cpu className="w-10 h-10" />}
title="CPU"
value={`${stats.cpu_percent}%`}
subtitle="Usage"
percent={stats.cpu_percent}
/>
<StatCard
icon={<MemoryStick className="w-10 h-10" />}
title="Memory"
value={`${stats.memory_used_mb} / ${stats.memory_total_mb} MB`}
subtitle={`${stats.memory_percent}% used`}
percent={stats.memory_percent}
/>
<StatCard
icon={<HardDrive className="w-10 h-10" />}
title="Disk"
value={`${stats.disk_used_gb} / ${stats.disk_total_gb} GB`}
subtitle={`${stats.disk_percent}% used`}
percent={stats.disk_percent}
/>
</div>
{network && (
<div className="mt-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 mb-3 text-gray-800 dark:text-white">
<Network className="w-5 h-5" />
<span className="font-medium">Network I/O</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Sent</span>
<p className="font-mono font-medium">{network.bytes_sent_mb} MB</p>
</div>
<div>
<span className="text-gray-500">Received</span>
<p className="font-mono font-medium">{network.bytes_recv_mb} MB</p>
</div>
</div>
</div>
)}
<div className="mt-6 bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<Cpu className="w-5 h-5" />
<span className="font-medium text-gray-800 dark:text-white">Top Processes (by CPU)</span>
</div>
<div className="max-h-80 overflow-y-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700 sticky top-0">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300">PID</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300">Name</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300">User</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300">CPU %</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-700 dark:text-gray-300">Mem %</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-700 dark:text-gray-300">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{processes.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-4 text-center text-gray-500 text-sm">No process data</td>
</tr>
) : (
processes.map((p) => (
<tr key={p.pid} className="text-sm">
<td className="px-4 py-1.5 font-mono text-gray-700 dark:text-gray-300">{p.pid}</td>
<td className="px-4 py-1.5 text-gray-900 dark:text-white truncate max-w-[120px]" title={p.name}>{p.name}</td>
<td className="px-4 py-1.5 text-gray-600 dark:text-gray-400">{p.username}</td>
<td className="px-4 py-1.5 text-right font-mono">{p.cpu_percent}%</td>
<td className="px-4 py-1.5 text-right font-mono">{p.memory_percent}%</td>
<td className="px-4 py-1.5 text-gray-500 text-xs">{p.status}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<div className="mt-6 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<div className="flex items-center gap-2 text-amber-800 dark:text-amber-200">
<Activity className="w-5 h-5" />
<span className="font-medium">Live monitoring</span>
</div>
<p className="mt-2 text-sm text-amber-700 dark:text-amber-300">
System metrics, processes, and network stats are polled every 3 seconds.
</p>
</div>
</div>
)
}
function StatCard({
icon,
title,
value,
subtitle,
percent,
}: {
icon: React.ReactNode
title: string
value: string
subtitle: string
percent: number
}) {
const barColor = percent > 90 ? 'bg-red-500' : percent > 70 ? 'bg-amber-500' : 'bg-blue-500'
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center gap-4 mb-4">
<div className="p-3 rounded-lg bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
{icon}
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">{title}</p>
<p className="text-xl font-bold text-gray-800 dark:text-white">{value}</p>
<p className="text-xs text-gray-500">{subtitle}</p>
</div>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full ${barColor} transition-all duration-500`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,298 @@
import { useEffect, useState } from 'react'
import { apiRequest, nodeAddProcess } from '../api/client'
import { Play, Square, RotateCw, Trash2, Loader2, Plus } from 'lucide-react'
interface Pm2Process {
id: number
name: string
status: string
pid: number | null
uptime: number
restarts: number
memory: number
cpu: number
}
function formatUptime(ms: number): string {
if (!ms || ms < 0) return '-'
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m`
const h = Math.floor(m / 60)
return `${h}h`
}
function formatMemory(bytes: number): string {
if (!bytes) return '-'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
export function NodePage() {
const [processes, setProcesses] = useState<Pm2Process[]>([])
const [nodeVersion, setNodeVersion] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<number | null>(null)
const [showAdd, setShowAdd] = useState(false)
const [addScript, setAddScript] = useState('')
const [addName, setAddName] = useState('')
const [adding, setAdding] = useState(false)
const load = () => {
setLoading(true)
Promise.all([
apiRequest<{ processes: Pm2Process[]; error?: string }>('/node/processes'),
apiRequest<{ version: string | null; error?: string }>('/node/version'),
])
.then(([procData, verData]) => {
setProcesses(procData.processes || [])
setNodeVersion(verData.version || null)
setError(procData.error || verData.error || '')
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [])
const handleStart = (id: number) => {
setActionId(id)
apiRequest<{ status: boolean }>(`/node/${id}/start`, { method: 'POST' })
.then(load)
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
const handleStop = (id: number) => {
setActionId(id)
apiRequest<{ status: boolean }>(`/node/${id}/stop`, { method: 'POST' })
.then(load)
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
const handleRestart = (id: number) => {
setActionId(id)
apiRequest<{ status: boolean }>(`/node/${id}/restart`, { method: 'POST' })
.then(load)
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
const handleAdd = (e: React.FormEvent) => {
e.preventDefault()
if (!addScript.trim()) return
setAdding(true)
nodeAddProcess(addScript.trim(), addName.trim() || undefined)
.then(() => {
setShowAdd(false)
setAddScript('')
setAddName('')
load()
})
.catch((err) => setError(err.message))
.finally(() => setAdding(false))
}
const handleDelete = (id: number, name: string) => {
if (!confirm(`Delete PM2 process "${name}"?`)) return
setActionId(id)
apiRequest<{ status: boolean }>(`/node/${id}`, { method: 'DELETE' })
.then(load)
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
const isOnline = (status: string) =>
status?.toLowerCase() === 'online' || status?.toLowerCase() === 'launching'
if (loading) return <div className="text-gray-500">Loading...</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Node.js</h1>
<div className="flex items-center gap-2">
{nodeVersion && (
<span className="text-sm text-gray-600 dark:text-gray-400">
Node: {nodeVersion}
</span>
)}
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg"
>
<Plus className="w-4 h-4" />
Add Process
</button>
<button
onClick={load}
className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg"
>
<RotateCw className="w-4 h-4" />
Refresh
</button>
</div>
</div>
{showAdd && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add PM2 Process</h2>
<form onSubmit={handleAdd} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Script path</label>
<input
value={addScript}
onChange={(e) => setAddScript(e.target.value)}
placeholder="/www/wwwroot/app.js"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name (optional)</label>
<input
value={addName}
onChange={(e) => setAddName(e.target.value)}
placeholder="myapp"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
/>
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowAdd(false)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit" disabled={adding} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
{adding ? 'Starting...' : 'Start'}
</button>
</div>
</form>
</div>
</div>
)}
{error && (
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200">
{error}
</div>
)}
<div className="mb-4 p-3 rounded-lg bg-gray-100 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
PM2 process manager. Install with: <code className="font-mono">npm install -g pm2</code>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Name
</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Status
</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
PID
</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Uptime
</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">
Memory
</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{processes.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
No PM2 processes. Click &quot;Add Process&quot; or run <code className="font-mono">pm2 start app.js</code>.
</td>
</tr>
) : (
processes.map((p) => (
<tr key={p.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-4 py-2 font-mono text-gray-900 dark:text-white">{p.name}</td>
<td className="px-4 py-2">
<span
className={`text-sm ${
isOnline(p.status)
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{p.status}
</span>
</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{p.pid || '-'}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">
{formatUptime(p.uptime)}
</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">
{formatMemory(p.memory)}
</td>
<td className="px-4 py-2 text-right">
<span className="flex gap-1 justify-end">
{isOnline(p.status) ? (
<>
<button
onClick={() => handleRestart(p.id)}
disabled={actionId === p.id}
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
title="Restart"
>
{actionId === p.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RotateCw className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleStop(p.id)}
disabled={actionId === p.id}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50"
title="Stop"
>
<Square className="w-4 h-4" />
</button>
</>
) : (
<button
onClick={() => handleStart(p.id)}
disabled={actionId === p.id}
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded disabled:opacity-50"
title="Start"
>
{actionId === p.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
</button>
)}
<button
onClick={() => handleDelete(p.id, p.name)}
disabled={actionId === p.id}
className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 rounded disabled:opacity-50"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react'
import { apiRequest, addPluginFromUrl, deletePlugin } from '../api/client'
import { Puzzle, Check, Plus, Trash2 } from 'lucide-react'
interface Plugin {
id: string
name: string
version: string
desc: string
enabled: boolean
builtin?: boolean
db_id?: number
}
export function PluginsPage() {
const [plugins, setPlugins] = useState<Plugin[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showAdd, setShowAdd] = useState(false)
const [addUrl, setAddUrl] = useState('')
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState('')
const loadPlugins = () =>
apiRequest<{ plugins: Plugin[] }>('/plugin/list')
.then((data) => setPlugins(data.plugins || []))
.catch((err) => setError(err.message))
useEffect(() => {
setLoading(true)
loadPlugins().finally(() => setLoading(false))
}, [])
const handleAdd = (e: React.FormEvent) => {
e.preventDefault()
const url = addUrl.trim()
if (!url) return
setAdding(true)
setAddError('')
addPluginFromUrl(url)
.then(() => {
setShowAdd(false)
setAddUrl('')
loadPlugins()
})
.catch((err) => setAddError(err.message))
.finally(() => setAdding(false))
}
const handleDelete = (pluginId: string) => {
if (!confirm('Remove this plugin?')) return
deletePlugin(pluginId)
.then(loadPlugins)
.catch((err) => setError(err.message))
}
if (loading) return <div className="text-gray-500">Loading...</div>
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Plugins</h1>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
>
<Plus className="w-4 h-4" />
Add from URL
</button>
</div>
<div className="mb-4 p-3 rounded-lg bg-gray-100 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
Built-in extensions and third-party plugins. Add plugins from a JSON manifest URL (must include <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">id</code>, <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">name</code>, and optionally <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">version</code>, <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">desc</code>).
</div>
{showAdd && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add Plugin from URL</h2>
<form onSubmit={handleAdd} className="space-y-4">
{addError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">{addError}</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Manifest URL</label>
<input
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
placeholder="https://example.com/plugin.json"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => { setShowAdd(false); setAddError('') }} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit" disabled={adding} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
{adding ? 'Adding...' : 'Add'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{plugins.map((p) => (
<div
key={p.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-4 flex items-start gap-3"
>
<Puzzle className="w-8 h-8 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900 dark:text-white">{p.name}</h3>
{p.enabled && (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-xs">
<Check className="w-3 h-3" />
Enabled
</span>
)}
{!p.builtin && (
<button
onClick={() => handleDelete(p.id)}
className="p-1 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded ml-auto"
title="Remove"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{p.desc}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">v{p.version}{p.builtin ? ' (built-in)' : ''}</p>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,295 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
type InstallerConfig = { enabled: boolean; default_install_url: string }
export function RemoteInstallPage() {
const [cfg, setCfg] = useState<InstallerConfig | null>(null)
const [cfgError, setCfgError] = useState('')
const [host, setHost] = useState('')
const [port, setPort] = useState('22')
const [username, setUsername] = useState('root')
const [authType, setAuthType] = useState<'key' | 'password'>('key')
const [privateKey, setPrivateKey] = useState('')
const [passphrase, setPassphrase] = useState('')
const [password, setPassword] = useState('')
const [installUrl, setInstallUrl] = useState('')
const [log, setLog] = useState<string[]>([])
const [running, setRunning] = useState(false)
const [exitCode, setExitCode] = useState<number | null>(null)
const [formError, setFormError] = useState('')
const logRef = useRef<HTMLPreElement>(null)
useEffect(() => {
fetch('/api/v1/public-install/config')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((d: InstallerConfig) => {
setCfg(d)
setInstallUrl(d.default_install_url || '')
})
.catch(() => setCfgError('Could not load installer configuration'))
}, [])
useEffect(() => {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight
}, [log])
const appendLog = useCallback((line: string) => {
setLog((prev) => [...prev.slice(-2000), line])
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setFormError('')
setLog([])
setExitCode(null)
if (!cfg?.enabled) return
const urlField = installUrl.trim() || cfg.default_install_url
const auth =
authType === 'key'
? { type: 'key' as const, private_key: privateKey, passphrase: passphrase || null }
: { type: 'password' as const, password }
const body = {
host: host.trim(),
port: parseInt(port, 10) || 22,
username: username.trim(),
auth,
install_url: urlField === cfg.default_install_url ? null : urlField,
}
setRunning(true)
try {
const res = await fetch('/api/v1/public-install/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || `HTTP ${res.status}`)
}
const { job_id } = (await res.json()) as { job_id: string }
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${window.location.host}/api/v1/public-install/ws/${job_id}`)
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data as string)
if (data.type === 'line' && typeof data.text === 'string') appendLog(data.text)
if (data.type === 'done') setExitCode(typeof data.exit_code === 'number' ? data.exit_code : -1)
} catch {
appendLog(String(ev.data))
}
}
ws.onerror = () => appendLog('[websocket error]')
ws.onclose = () => setRunning(false)
} catch (err) {
setFormError(err instanceof Error ? err.message : 'Request failed')
setRunning(false)
} finally {
if (authType === 'password') setPassword('')
}
}
if (!cfg && !cfgError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<p className="text-gray-500">Loading</p>
</div>
)
}
if (cfgError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-4">
<p className="text-red-600">{cfgError}</p>
</div>
)
}
if (cfg && !cfg.enabled) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-4">
<div className="w-full max-w-lg p-8 bg-white dark:bg-gray-800 rounded-xl shadow-lg space-y-4">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Remote SSH installer disabled</h1>
<p className="text-gray-600 dark:text-gray-300 text-sm">
Enable it on the API server with environment variable{' '}
<code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">ENABLE_REMOTE_INSTALLER=true</code> and restart
the backend. Prefer SSH keys; exposing this endpoint increases risk.
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
One-liner on the target server (as root):{' '}
<code className="break-all block mt-2 bg-gray-900 text-green-400 p-2 rounded text-xs">
curl -fsSL {cfg.default_install_url} | bash
</code>
</p>
<Link to="/login" className="inline-block text-blue-600 hover:underline text-sm">
Panel login
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 py-8 px-4">
<div className="max-w-3xl mx-auto space-y-6">
<div className="bg-amber-100 dark:bg-amber-900/40 border border-amber-300 dark:border-amber-700 rounded-lg p-4 text-sm text-amber-950 dark:text-amber-100">
<strong>Security warning:</strong> SSH credentials are sent to this panel API and used only for this session
(not stored). Prefer <strong>SSH private keys</strong>. Root password SSH is often disabled on Ubuntu. Non-root
users need <strong>passwordless sudo</strong> (<code>sudo -n</code>) to run the installer. Allow SSH from this
server to the target on port {port}.
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">Remote install (SSH)</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
Runs the published <code>install.sh</code> on the target via SSH (same as shell one-liner).
</p>
<form onSubmit={handleSubmit} className="space-y-4">
{formError && (
<div className="p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
{formError}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Host</label>
<input
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="203.0.113.50"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SSH port</label>
<input
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
min={1}
max={65535}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SSH username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authentication</span>
<div className="flex gap-4">
<label className="inline-flex items-center gap-2 text-sm text-gray-800 dark:text-gray-200">
<input
type="radio"
name="auth"
checked={authType === 'key'}
onChange={() => setAuthType('key')}
/>
Private key
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-800 dark:text-gray-200">
<input
type="radio"
name="auth"
checked={authType === 'password'}
onChange={() => setAuthType('password')}
/>
Password
</label>
</div>
</div>
{authType === 'key' ? (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Private key (PEM)</label>
<textarea
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
rows={6}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-xs"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Key passphrase (optional)
</label>
<input
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
</>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SSH password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Install script URL (https only)
</label>
<input
type="url"
value={installUrl}
onChange={(e) => setInstallUrl(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
/>
</div>
<button
type="submit"
disabled={running || !cfg?.enabled || !cfg}
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
>
{running ? 'Running…' : 'Start remote install'}
</button>
</form>
</div>
{log.length > 0 && (
<div className="bg-gray-900 text-gray-100 rounded-xl p-4 font-mono text-xs overflow-hidden">
<pre ref={logRef} className="max-h-96 overflow-auto whitespace-pre-wrap break-words">
{log.join('\n')}
</pre>
{exitCode !== null && (
<p className="mt-2 text-sm border-t border-gray-700 pt-2">
Exit code: <strong>{exitCode}</strong>
</p>
)}
</div>
)}
<p className="text-center text-sm text-gray-500">
<Link to="/login" className="text-blue-600 hover:underline">
Panel login
</Link>
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react'
import { apiRequest } from '../api/client'
import { Play, Square, RotateCw, Loader2 } from 'lucide-react'
interface Service {
id: string
name: string
unit: string
status: string
}
export function ServicesPage() {
const [services, setServices] = useState<Service[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(null)
const load = () => {
setLoading(true)
apiRequest<{ services: Service[] }>('/service/list')
.then((data) => setServices(data.services || []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [])
const handleStart = (id: string) => {
setActionId(id)
apiRequest<{ status: boolean }>(`/service/${id}/start`, { method: 'POST' })
.then(load)
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
const handleStop = (id: string) => {
setActionId(id)
apiRequest<{ status: boolean }>(`/service/${id}/stop`, { method: 'POST' })
.then(load)
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
const handleRestart = (id: string) => {
setActionId(id)
apiRequest<{ status: boolean }>(`/service/${id}/restart`, { method: 'POST' })
.then(load)
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
const isActive = (status: string) => status === 'active' || status === 'activating'
if (loading) return <div className="text-gray-500">Loading...</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Services</h1>
<button
onClick={load}
className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg"
>
<RotateCw className="w-4 h-4" />
Refresh
</button>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
{error}
</div>
)}
<div className="mb-4 p-3 rounded-lg bg-gray-100 dark:bg-gray-700/50 text-sm text-gray-600 dark:text-gray-400">
Control system services via systemctl. Requires panel to run with sufficient privileges.
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Service</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Unit</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{services.map((s) => (
<tr key={s.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="px-4 py-2 text-gray-900 dark:text-white">{s.name}</td>
<td className="px-4 py-2 font-mono text-gray-600 dark:text-gray-400 text-sm">{s.unit}</td>
<td className="px-4 py-2">
<span
className={`text-sm ${
isActive(s.status) ? 'text-green-600 dark:text-green-400' : 'text-gray-500 dark:text-gray-400'
}`}
>
{s.status}
</span>
</td>
<td className="px-4 py-2 text-right">
<span className="flex gap-1 justify-end">
{isActive(s.status) ? (
<>
<button
onClick={() => handleRestart(s.id)}
disabled={!!actionId}
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
title="Restart"
>
{actionId === s.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <RotateCw className="w-4 h-4" />}
</button>
<button
onClick={() => handleStop(s.id)}
disabled={!!actionId}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50"
title="Stop"
>
<Square className="w-4 h-4" />
</button>
</>
) : (
<button
onClick={() => handleStart(s.id)}
disabled={!!actionId}
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded disabled:opacity-50"
title="Start"
>
{actionId === s.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
</button>
)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,697 @@
import { useEffect, useState } from 'react'
import {
apiRequest,
createSite,
getSite,
updateSite,
setSiteStatus,
deleteSite,
createSiteBackup,
listSiteBackups,
restoreSiteBackup,
downloadSiteBackup,
listSiteRedirects,
addSiteRedirect,
deleteSiteRedirect,
siteGitClone,
siteGitPull,
} from '../api/client'
import { Plus, Trash2, Download, Archive, RotateCcw, Pencil, Play, Square, Redirect, GitBranch } from 'lucide-react'
interface Site {
id: number
name: string
path: string
status: number
ps: string
project_type: string
domain_count: number
addtime: string | null
}
export function SitePage() {
const [sites, setSites] = useState<Site[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [creating, setCreating] = useState(false)
const [creatingError, setCreatingError] = useState('')
const [backupSiteId, setBackupSiteId] = useState<number | null>(null)
const [backups, setBackups] = useState<{ filename: string; size: number }[]>([])
const [backupLoading, setBackupLoading] = useState(false)
const [editSiteId, setEditSiteId] = useState<number | null>(null)
const [editForm, setEditForm] = useState<{ domains: string; path: string; ps: string; php_version: string; force_https: boolean } | null>(null)
const [editLoading, setEditLoading] = useState(false)
const [editError, setEditError] = useState('')
const [statusLoading, setStatusLoading] = useState<number | null>(null)
const [redirectSiteId, setRedirectSiteId] = useState<number | null>(null)
const [redirects, setRedirects] = useState<{ id: number; source: string; target: string; code: number }[]>([])
const [redirectSource, setRedirectSource] = useState('')
const [redirectTarget, setRedirectTarget] = useState('')
const [redirectCode, setRedirectCode] = useState(301)
const [redirectAdding, setRedirectAdding] = useState(false)
const [gitSiteId, setGitSiteId] = useState<number | null>(null)
const [gitUrl, setGitUrl] = useState('')
const [gitBranch, setGitBranch] = useState('main')
const [gitAction, setGitAction] = useState<'clone' | 'pull' | null>(null)
const [gitLoading, setGitLoading] = useState(false)
const loadSites = () => {
setLoading(true)
apiRequest<Site[]>('/site/list')
.then(setSites)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
loadSites()
}, [])
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim()
const domainsStr = (form.elements.namedItem('domains') as HTMLInputElement).value.trim()
const path = (form.elements.namedItem('path') as HTMLInputElement).value.trim()
const ps = (form.elements.namedItem('ps') as HTMLInputElement).value.trim()
if (!name || !domainsStr) {
setCreatingError('Name and domain(s) are required')
return
}
const domains = domainsStr.split(/[\s,]+/).filter(Boolean)
const php_version = (form.elements.namedItem('php_version') as HTMLSelectElement)?.value || '74'
const force_https = (form.elements.namedItem('force_https') as HTMLInputElement)?.checked || false
setCreating(true)
setCreatingError('')
createSite({ name, domains, path: path || undefined, ps: ps || undefined, php_version, force_https })
.then(() => {
setShowCreate(false)
form.reset()
loadSites()
})
.catch((err) => setCreatingError(err.message))
.finally(() => setCreating(false))
}
const handleDelete = (id: number, name: string) => {
if (!confirm(`Delete site "${name}"? This cannot be undone.`)) return
deleteSite(id)
.then(loadSites)
.catch((err) => setError(err.message))
}
const openBackupModal = (siteId: number) => {
setBackupSiteId(siteId)
setBackups([])
listSiteBackups(siteId)
.then((data) => setBackups(data.backups))
.catch((err) => setError(err.message))
}
const handleCreateBackup = () => {
if (!backupSiteId) return
setBackupLoading(true)
createSiteBackup(backupSiteId)
.then(() => listSiteBackups(backupSiteId).then((d) => setBackups(d.backups)))
.catch((err) => setError(err.message))
.finally(() => setBackupLoading(false))
}
const handleRestore = (filename: string) => {
if (!backupSiteId || !confirm(`Restore from ${filename}? This will overwrite existing files.`)) return
setBackupLoading(true)
restoreSiteBackup(backupSiteId, filename)
.then(() => setBackupSiteId(null))
.catch((err) => setError(err.message))
.finally(() => setBackupLoading(false))
}
const handleDownloadBackup = (filename: string) => {
if (!backupSiteId) return
downloadSiteBackup(backupSiteId, filename).catch((err) => setError(err.message))
}
const openEditModal = (siteId: number) => {
setEditSiteId(siteId)
setEditError('')
getSite(siteId)
.then((s) => setEditForm({
domains: (s.domains || []).join(', '),
path: s.path || '',
ps: s.ps || '',
php_version: s.php_version || '74',
force_https: !!(s.force_https && s.force_https !== 0),
}))
.catch((err) => setEditError(err.message))
}
const handleEdit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!editSiteId || !editForm) return
const domains = editForm.domains.split(/[\s,]+/).filter(Boolean)
if (domains.length === 0) {
setEditError('At least one domain is required')
return
}
setEditLoading(true)
setEditError('')
updateSite(editSiteId, {
domains,
path: editForm.path || undefined,
ps: editForm.ps || undefined,
php_version: editForm.php_version,
force_https: editForm.force_https,
})
.then(() => {
setEditSiteId(null)
setEditForm(null)
loadSites()
})
.catch((err) => setEditError(err.message))
.finally(() => setEditLoading(false))
}
const handleSetStatus = (siteId: number, enable: boolean) => {
setStatusLoading(siteId)
setSiteStatus(siteId, enable)
.then(loadSites)
.catch((err) => setError(err.message))
.finally(() => setStatusLoading(null))
}
const openRedirectModal = (siteId: number) => {
setRedirectSiteId(siteId)
setRedirectSource('')
setRedirectTarget('')
setRedirectCode(301)
listSiteRedirects(siteId)
.then(setRedirects)
.catch((err) => setError(err.message))
}
const handleAddRedirect = (e: React.FormEvent) => {
e.preventDefault()
if (!redirectSiteId || !redirectSource.trim() || !redirectTarget.trim()) return
setRedirectAdding(true)
addSiteRedirect(redirectSiteId, redirectSource.trim(), redirectTarget.trim(), redirectCode)
.then(() => listSiteRedirects(redirectSiteId).then(setRedirects))
.then(() => { setRedirectSource(''); setRedirectTarget('') })
.catch((err) => setError(err.message))
.finally(() => setRedirectAdding(false))
}
const handleDeleteRedirect = (redirectId: number) => {
if (!redirectSiteId) return
deleteSiteRedirect(redirectSiteId, redirectId)
.then(() => listSiteRedirects(redirectSiteId).then(setRedirects))
.catch((err) => setError(err.message))
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
if (loading) return <div className="text-gray-500">Loading...</div>
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Website</h1>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
>
<Plus className="w-4 h-4" />
Add Site
</button>
</div>
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Create Site</h2>
<form onSubmit={handleCreate} className="space-y-4">
{creatingError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
{creatingError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Site Name
</label>
<input
name="name"
type="text"
placeholder="example.com"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Domain(s) <span className="text-gray-500">(comma or space separated)</span>
</label>
<input
name="domains"
type="text"
placeholder="example.com www.example.com"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Path <span className="text-gray-500">(optional)</span>
</label>
<input
name="path"
type="text"
placeholder="/www/wwwroot/example.com"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
PHP Version
</label>
<select
name="php_version"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="74">7.4</option>
<option value="80">8.0</option>
<option value="81">8.1</option>
<option value="82">8.2</option>
</select>
</div>
<div className="flex items-center gap-2">
<input name="force_https" type="checkbox" id="create_force_https" className="rounded" />
<label htmlFor="create_force_https" className="text-sm text-gray-700 dark:text-gray-300">
Force HTTPS (redirect HTTP to HTTPS)
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Note <span className="text-gray-500">(optional)</span>
</label>
<input
name="ps"
type="text"
placeholder="My website"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
onClick={() => setShowCreate(false)}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Name</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Path</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Domains</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Type</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{sites.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
No sites yet. Click "Add Site" to create one.
</td>
</tr>
) : (
sites.map((s) => (
<tr key={s.id}>
<td className="px-4 py-2 text-gray-900 dark:text-white">{s.name}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{s.path}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{s.domain_count}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{s.project_type}</td>
<td className="px-4 py-2 text-right">
<span className="flex gap-1 justify-end">
{s.status === 1 ? (
<button
onClick={() => handleSetStatus(s.id, false)}
disabled={statusLoading === s.id}
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
title="Stop"
>
<Square className="w-4 h-4" />
</button>
) : (
<button
onClick={() => handleSetStatus(s.id, true)}
disabled={statusLoading === s.id}
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded disabled:opacity-50"
title="Start"
>
<Play className="w-4 h-4" />
</button>
)}
<button
onClick={() => { setGitSiteId(s.id); setGitAction('clone'); setGitUrl(''); setGitBranch('main') }}
className="p-2 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded"
title="Git Deploy"
>
<GitBranch className="w-4 h-4" />
</button>
<button
onClick={() => openRedirectModal(s.id)}
className="p-2 text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded"
title="Redirects"
>
<Redirect className="w-4 h-4" />
</button>
<button
onClick={() => openEditModal(s.id)}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
title="Edit"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => openBackupModal(s.id)}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
title="Backup"
>
<Archive className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(s.id, s.name)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{gitSiteId && gitAction && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Git Deploy</h2>
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setGitAction('clone')}
className={`px-3 py-1.5 rounded text-sm ${gitAction === 'clone' ? 'bg-emerald-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}`}
>
Clone
</button>
<button
type="button"
onClick={() => setGitAction('pull')}
className={`px-3 py-1.5 rounded text-sm ${gitAction === 'pull' ? 'bg-emerald-600 text-white' : 'bg-gray-200 dark:bg-gray-700'}`}
>
Pull
</button>
</div>
{gitAction === 'clone' ? (
<form onSubmit={handleGitClone} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Repository URL</label>
<input
value={gitUrl}
onChange={(e) => setGitUrl(e.target.value)}
placeholder="https://github.com/user/repo.git"
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Branch</label>
<input
value={gitBranch}
onChange={(e) => setGitBranch(e.target.value)}
placeholder="main"
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
/>
</div>
<p className="text-xs text-gray-500">Site path must be empty. This will clone the repo into the site directory.</p>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => { setGitSiteId(null); setGitAction(null) }} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">Cancel</button>
<button type="submit" disabled={gitLoading} className="px-4 py-2 bg-emerald-600 text-white rounded-lg disabled:opacity-50">
{gitLoading ? 'Cloning...' : 'Clone'}
</button>
</div>
</form>
) : (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">Pull latest changes from the remote repository.</p>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => { setGitSiteId(null); setGitAction(null) }} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">Cancel</button>
<button onClick={handleGitPull} disabled={gitLoading} className="px-4 py-2 bg-emerald-600 text-white rounded-lg disabled:opacity-50">
{gitLoading ? 'Pulling...' : 'Pull'}
</button>
</div>
</div>
)}
</div>
</div>
)}
{redirectSiteId && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-lg">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Redirects</h2>
<form onSubmit={handleAddRedirect} className="space-y-2 mb-4">
<div className="flex gap-2 flex-wrap">
<input
value={redirectSource}
onChange={(e) => setRedirectSource(e.target.value)}
placeholder="/old-path"
className="flex-1 min-w-[100px] px-3 py-2 border rounded-lg bg-white dark:bg-gray-700"
/>
<span className="self-center text-gray-500"></span>
<input
value={redirectTarget}
onChange={(e) => setRedirectTarget(e.target.value)}
placeholder="/new-path or https://..."
className="flex-1 min-w-[100px] px-3 py-2 border rounded-lg bg-white dark:bg-gray-700"
/>
<select
value={redirectCode}
onChange={(e) => setRedirectCode(Number(e.target.value))}
className="px-3 py-2 border rounded-lg bg-white dark:bg-gray-700"
>
<option value={301}>301</option>
<option value={302}>302</option>
</select>
<button type="submit" disabled={redirectAdding} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
Add
</button>
</div>
</form>
<div className="max-h-48 overflow-y-auto">
{redirects.length === 0 ? (
<p className="text-gray-500 text-sm">No redirects</p>
) : (
<ul className="space-y-2">
{redirects.map((r) => (
<li key={r.id} className="flex items-center justify-between gap-2 text-sm py-1 border-b dark:border-gray-700">
<span className="font-mono truncate">{r.source}</span>
<span className="text-gray-500"></span>
<span className="font-mono truncate flex-1">{r.target}</span>
<span className="text-gray-400">{r.code}</span>
<button onClick={() => handleDeleteRedirect(r.id)} className="p-1 text-red-600 hover:bg-red-50 rounded">
<Trash2 className="w-4 h-4" />
</button>
</li>
))}
</ul>
)}
</div>
<div className="mt-4 flex justify-end">
<button onClick={() => setRedirectSiteId(null)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
Close
</button>
</div>
</div>
</div>
)}
{editSiteId && editForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Edit Site</h2>
<form onSubmit={handleEdit} className="space-y-4">
{editError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
{editError}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Domain(s) <span className="text-gray-500">(comma or space separated)</span>
</label>
<input
value={editForm.domains}
onChange={(e) => setEditForm({ ...editForm, domains: e.target.value })}
type="text"
placeholder="example.com www.example.com"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Path <span className="text-gray-500">(optional)</span>
</label>
<input
value={editForm.path}
onChange={(e) => setEditForm({ ...editForm, path: e.target.value })}
type="text"
placeholder="/www/wwwroot/example.com"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">PHP Version</label>
<select
value={editForm.php_version}
onChange={(e) => setEditForm({ ...editForm, php_version: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="74">7.4</option>
<option value="80">8.0</option>
<option value="81">8.1</option>
<option value="82">8.2</option>
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="edit_force_https"
checked={editForm.force_https}
onChange={(e) => setEditForm({ ...editForm, force_https: e.target.checked })}
className="rounded"
/>
<label htmlFor="edit_force_https" className="text-sm text-gray-700 dark:text-gray-300">
Force HTTPS
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Note <span className="text-gray-500">(optional)</span>
</label>
<input
value={editForm.ps}
onChange={(e) => setEditForm({ ...editForm, ps: e.target.value })}
type="text"
placeholder="My website"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div>
<div className="flex gap-2 justify-end pt-2">
<button
type="button"
onClick={() => { setEditSiteId(null); setEditForm(null) }}
className="px-4 py-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={editLoading}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg font-medium"
>
{editLoading ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
)}
{backupSiteId && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-lg">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Site Backup</h2>
<div className="mb-4">
<button
onClick={handleCreateBackup}
disabled={backupLoading}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
>
<Archive className="w-4 h-4" />
{backupLoading ? 'Creating...' : 'Create Backup'}
</button>
</div>
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Existing backups</h3>
{backups.length === 0 ? (
<p className="text-gray-500 text-sm">No backups yet</p>
) : (
<ul className="space-y-2 max-h-48 overflow-y-auto">
{backups.map((b) => (
<li key={b.filename} className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-mono text-gray-700 dark:text-gray-300 flex-1">{b.filename}</span>
<span className="text-gray-500 text-xs flex-shrink-0">{formatSize(b.size)}</span>
<span className="flex gap-1 flex-shrink-0">
<button
onClick={() => handleDownloadBackup(b.filename)}
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
title="Download"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => handleRestore(b.filename)}
disabled={backupLoading}
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded disabled:opacity-50"
title="Restore"
>
<RotateCcw className="w-4 h-4" />
</button>
</span>
</li>
))}
</ul>
)}
</div>
<div className="flex justify-end">
<button
onClick={() => setBackupSiteId(null)}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react'
import { apiRequest } from '../api/client'
import { Check, X, Loader2, Package } from 'lucide-react'
interface Software {
id: string
name: string
desc: string
pkg: string
installed: boolean
version: string
}
export function SoftPage() {
const [software, setSoftware] = useState<Software[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [actionId, setActionId] = useState<string | null>(null)
const load = () => {
setLoading(true)
apiRequest<{ software: Software[] }>('/soft/list')
.then((data) => setSoftware(data.software || []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
}, [])
const handleInstall = (id: string) => {
setActionId(id)
setError('')
apiRequest<{ status: boolean }>(`/soft/install/${id}`, { method: 'POST' })
.then(() => load())
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
const handleUninstall = (id: string, name: string) => {
if (!confirm(`Uninstall ${name}?`)) return
setActionId(id)
setError('')
apiRequest<{ status: boolean }>(`/soft/uninstall/${id}`, { method: 'POST' })
.then(() => load())
.catch((err) => setError(err.message))
.finally(() => setActionId(null))
}
if (loading) return <div className="text-gray-500">Loading...</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">App Store</h1>
<button
onClick={load}
className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg"
>
Refresh
</button>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
{error}
</div>
)}
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200 text-sm">
Install/uninstall via apt. Panel must run with sufficient privileges. Target: Debian/Ubuntu.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{software.map((s) => (
<div
key={s.id}
className="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-4 flex flex-col"
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<Package className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">{s.name}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{s.desc}</p>
</div>
</div>
{s.installed ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-sm flex-shrink-0">
<Check className="w-4 h-4" />
{s.version || 'Installed'}
</span>
) : (
<span className="flex items-center gap-1 text-gray-500 text-sm flex-shrink-0">
<X className="w-4 h-4" />
Not installed
</span>
)}
</div>
<div className="mt-auto pt-3">
{s.installed ? (
<button
onClick={() => handleUninstall(s.id, s.name)}
disabled={actionId === s.id}
className="w-full px-3 py-2 text-sm rounded-lg border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 flex items-center justify-center gap-2"
>
{actionId === s.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Uninstall'
)}
</button>
) : (
<button
onClick={() => handleInstall(s.id)}
disabled={actionId === s.id}
className="w-full px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 flex items-center justify-center gap-2"
>
{actionId === s.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Install'
)}
</button>
)}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { useEffect, useRef } from 'react'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import 'xterm/css/xterm.css'
export function TerminalPage() {
const containerRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<Terminal | null>(null)
useEffect(() => {
if (!containerRef.current) return
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const token = localStorage.getItem('token')
const wsUrl = `${protocol}//${host}/api/v1/terminal/ws${token ? `?token=${token}` : ''}`
const term = new Terminal({
cursorBlink: true,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
},
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(containerRef.current)
fitAddon.fit()
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
ws.onopen = () => {
term.writeln('YakPanel Terminal - Connected')
term.writeln('')
}
ws.onmessage = (ev) => {
if (typeof ev.data === 'string') {
term.write(ev.data)
} else {
const decoder = new TextDecoder()
term.write(decoder.decode(ev.data))
}
}
ws.onclose = () => {
term.writeln('\r\n\r\nDisconnected from server.')
}
ws.onerror = () => {
term.writeln('\r\n\r\nConnection error.')
}
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
const resize = () => fitAddon.fit()
window.addEventListener('resize', resize)
terminalRef.current = term
return () => {
window.removeEventListener('resize', resize)
ws.close()
term.dispose()
terminalRef.current = null
}
}, [])
return (
<div>
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Terminal</h1>
<div
ref={containerRef}
className="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700"
style={{ height: 'calc(100vh - 200px)', minHeight: 400 }}
/>
</div>
)
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useState } from 'react'
import { apiRequest, listUsers, createUser, deleteUser, toggleUserActive } from '../api/client'
import { Plus, Trash2, UserCheck, UserX } from 'lucide-react'
interface UserRecord {
id: number
username: string
email: string
is_active: boolean
is_superuser: boolean
}
export function UsersPage() {
const [users, setUsers] = useState<UserRecord[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState('')
const [currentUserId, setCurrentUserId] = useState<number | null>(null)
const loadUsers = () => {
setLoading(true)
listUsers()
.then((data) => {
setUsers(data)
apiRequest<{ id: number }>('/auth/me').then((me) => setCurrentUserId(me.id))
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => {
loadUsers()
}, [])
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
const username = (form.elements.namedItem('username') as HTMLInputElement).value.trim()
const password = (form.elements.namedItem('password') as HTMLInputElement).value
const email = (form.elements.namedItem('email') as HTMLInputElement).value.trim()
if (!username || username.length < 2) {
setCreateError('Username must be at least 2 characters')
return
}
if (!password || password.length < 6) {
setCreateError('Password must be at least 6 characters')
return
}
setCreating(true)
setCreateError('')
createUser({ username, password, email })
.then(() => {
setShowCreate(false)
form.reset()
loadUsers()
})
.catch((err) => setCreateError(err.message))
.finally(() => setCreating(false))
}
const handleDelete = (id: number, username: string) => {
if (!confirm(`Delete user "${username}"?`)) return
deleteUser(id)
.then(loadUsers)
.catch((err) => setError(err.message))
}
const handleToggleActive = (id: number) => {
toggleUserActive(id)
.then(loadUsers)
.catch((err) => setError(err.message))
}
if (loading && users.length === 0) return <div className="text-gray-500">Loading...</div>
if (error) return <div className="p-4 rounded bg-red-100 text-red-700">{error}</div>
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Users</h1>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
>
<Plus className="w-4 h-4" />
Add User
</button>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Username</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Email</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Status</th>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-700 dark:text-gray-300">Role</th>
<th className="px-4 py-2 text-right text-sm font-medium text-gray-700 dark:text-gray-300">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{users.map((u) => (
<tr key={u.id}>
<td className="px-4 py-2 text-gray-900 dark:text-white">{u.username}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{u.email || '-'}</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">
<span className={u.is_active ? 'text-green-600' : 'text-gray-500'}>{u.is_active ? 'Active' : 'Inactive'}</span>
</td>
<td className="px-4 py-2 text-gray-600 dark:text-gray-400">{u.is_superuser ? 'Admin' : 'User'}</td>
<td className="px-4 py-2 text-right">
<span className="flex gap-1 justify-end">
{u.id !== currentUserId && (
<>
<button
onClick={() => handleToggleActive(u.id)}
className={`p-2 rounded ${u.is_active ? 'text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20' : 'text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'}`}
title={u.is_active ? 'Deactivate' : 'Activate'}
>
{u.is_active ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
</button>
<button
onClick={() => handleDelete(u.id, u.username)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add User</h2>
<form onSubmit={handleCreate} className="space-y-4">
{createError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">{createError}</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username</label>
<input
name="username"
type="text"
placeholder="newuser"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
minLength={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<input
name="password"
type="password"
placeholder="••••••••"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
required
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email (optional)</label>
<input
name="email"
type="email"
placeholder="user@example.com"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700"
/>
</div>
<div className="flex gap-2 justify-end pt-2">
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit" disabled={creating} className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50">
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8888',
changeOrigin: true,
ws: true,
},
},
},
})