Initial YakPanel commit
This commit is contained in:
11
YakPanel-server/frontend/Dockerfile
Normal file
11
YakPanel-server/frontend/Dockerfile
Normal 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
|
||||
13
YakPanel-server/frontend/index.html
Normal file
13
YakPanel-server/frontend/index.html
Normal 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>
|
||||
16
YakPanel-server/frontend/nginx.conf
Normal file
16
YakPanel-server/frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
31
YakPanel-server/frontend/package.json
Normal file
31
YakPanel-server/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
YakPanel-server/frontend/postcss.config.js
Normal file
6
YakPanel-server/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
4
YakPanel-server/frontend/public/favicon.svg
Normal file
4
YakPanel-server/frontend/public/favicon.svg
Normal 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 |
74
YakPanel-server/frontend/src/App.tsx
Normal file
74
YakPanel-server/frontend/src/App.tsx
Normal 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
|
||||
}
|
||||
440
YakPanel-server/frontend/src/api/client.ts
Normal file
440
YakPanel-server/frontend/src/api/client.ts
Normal 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' })
|
||||
}
|
||||
56
YakPanel-server/frontend/src/components/Layout.tsx
Normal file
56
YakPanel-server/frontend/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
YakPanel-server/frontend/src/config/menu.ts
Normal file
29
YakPanel-server/frontend/src/config/menu.ts
Normal 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 },
|
||||
]
|
||||
9
YakPanel-server/frontend/src/index.css
Normal file
9
YakPanel-server/frontend/src/index.css
Normal 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;
|
||||
}
|
||||
13
YakPanel-server/frontend/src/main.tsx
Normal file
13
YakPanel-server/frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
395
YakPanel-server/frontend/src/pages/BackupPlansPage.tsx
Normal file
395
YakPanel-server/frontend/src/pages/BackupPlansPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
245
YakPanel-server/frontend/src/pages/ConfigPage.tsx
Normal file
245
YakPanel-server/frontend/src/pages/ConfigPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
YakPanel-server/frontend/src/pages/CrontabPage.tsx
Normal file
205
YakPanel-server/frontend/src/pages/CrontabPage.tsx
Normal 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 "Apply to System" 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>
|
||||
)
|
||||
}
|
||||
112
YakPanel-server/frontend/src/pages/DashboardPage.tsx
Normal file
112
YakPanel-server/frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
388
YakPanel-server/frontend/src/pages/DatabasePage.tsx
Normal file
388
YakPanel-server/frontend/src/pages/DatabasePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
335
YakPanel-server/frontend/src/pages/DockerPage.tsx
Normal file
335
YakPanel-server/frontend/src/pages/DockerPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
215
YakPanel-server/frontend/src/pages/DomainsPage.tsx
Normal file
215
YakPanel-server/frontend/src/pages/DomainsPage.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
354
YakPanel-server/frontend/src/pages/FilesPage.tsx
Normal file
354
YakPanel-server/frontend/src/pages/FilesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
219
YakPanel-server/frontend/src/pages/FirewallPage.tsx
Normal file
219
YakPanel-server/frontend/src/pages/FirewallPage.tsx
Normal 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 "Apply to UFW" 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>
|
||||
)
|
||||
}
|
||||
257
YakPanel-server/frontend/src/pages/FtpPage.tsx
Normal file
257
YakPanel-server/frontend/src/pages/FtpPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
YakPanel-server/frontend/src/pages/LoginPage.tsx
Normal file
81
YakPanel-server/frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
YakPanel-server/frontend/src/pages/LogsPage.tsx
Normal file
192
YakPanel-server/frontend/src/pages/LogsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
199
YakPanel-server/frontend/src/pages/MonitorPage.tsx
Normal file
199
YakPanel-server/frontend/src/pages/MonitorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
298
YakPanel-server/frontend/src/pages/NodePage.tsx
Normal file
298
YakPanel-server/frontend/src/pages/NodePage.tsx
Normal 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 "Add Process" 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>
|
||||
)
|
||||
}
|
||||
142
YakPanel-server/frontend/src/pages/PluginsPage.tsx
Normal file
142
YakPanel-server/frontend/src/pages/PluginsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
295
YakPanel-server/frontend/src/pages/RemoteInstallPage.tsx
Normal file
295
YakPanel-server/frontend/src/pages/RemoteInstallPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
145
YakPanel-server/frontend/src/pages/ServicesPage.tsx
Normal file
145
YakPanel-server/frontend/src/pages/ServicesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
697
YakPanel-server/frontend/src/pages/SitePage.tsx
Normal file
697
YakPanel-server/frontend/src/pages/SitePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
YakPanel-server/frontend/src/pages/SoftPage.tsx
Normal file
133
YakPanel-server/frontend/src/pages/SoftPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
YakPanel-server/frontend/src/pages/TerminalPage.tsx
Normal file
84
YakPanel-server/frontend/src/pages/TerminalPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
194
YakPanel-server/frontend/src/pages/UsersPage.tsx
Normal file
194
YakPanel-server/frontend/src/pages/UsersPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
YakPanel-server/frontend/src/vite-env.d.ts
vendored
Normal file
1
YakPanel-server/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
12
YakPanel-server/frontend/tailwind.config.js
Normal file
12
YakPanel-server/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
YakPanel-server/frontend/tsconfig.json
Normal file
25
YakPanel-server/frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
YakPanel-server/frontend/tsconfig.node.json
Normal file
10
YakPanel-server/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
YakPanel-server/frontend/vite.config.ts
Normal file
16
YakPanel-server/frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user