new changes

This commit is contained in:
Niranjan
2026-04-07 05:05:28 +05:30
parent 7c070224bd
commit a18bba15f2
29975 changed files with 3247495 additions and 2761 deletions

View File

@@ -1,26 +1,46 @@
import { lazy, Suspense } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom'
import { Layout } from './components/Layout'
import { ThemeProvider } from './context/ThemeContext'
import { AdminLayout } from './components/admin/AdminLayout'
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'
import { SkeletonCard } from './components/admin/Skeleton'
const DashboardPage = lazy(() => import('./pages/DashboardPage').then((m) => ({ default: m.DashboardPage })))
const SitePage = lazy(() => import('./pages/SitePage').then((m) => ({ default: m.SitePage })))
const FilesPage = lazy(() => import('./pages/FilesPage').then((m) => ({ default: m.FilesPage })))
const FtpPage = lazy(() => import('./pages/FtpPage').then((m) => ({ default: m.FtpPage })))
const DatabasePage = lazy(() => import('./pages/DatabasePage').then((m) => ({ default: m.DatabasePage })))
const TerminalPage = lazy(() => import('./pages/TerminalPage').then((m) => ({ default: m.TerminalPage })))
const MonitorPage = lazy(() => import('./pages/MonitorPage').then((m) => ({ default: m.MonitorPage })))
const CrontabPage = lazy(() => import('./pages/CrontabPage').then((m) => ({ default: m.CrontabPage })))
const ConfigPage = lazy(() => import('./pages/ConfigPage').then((m) => ({ default: m.ConfigPage })))
const LogsPage = lazy(() => import('./pages/LogsPage').then((m) => ({ default: m.LogsPage })))
const FirewallPage = lazy(() => import('./pages/FirewallPage').then((m) => ({ default: m.FirewallPage })))
const DomainsPage = lazy(() => import('./pages/DomainsPage').then((m) => ({ default: m.DomainsPage })))
const DockerPage = lazy(() => import('./pages/DockerPage').then((m) => ({ default: m.DockerPage })))
const NodePage = lazy(() => import('./pages/NodePage').then((m) => ({ default: m.NodePage })))
const SoftPage = lazy(() => import('./pages/SoftPage').then((m) => ({ default: m.SoftPage })))
const ServicesPage = lazy(() => import('./pages/ServicesPage').then((m) => ({ default: m.ServicesPage })))
const PluginsPage = lazy(() => import('./pages/PluginsPage').then((m) => ({ default: m.PluginsPage })))
const BackupPlansPage = lazy(() => import('./pages/BackupPlansPage').then((m) => ({ default: m.BackupPlansPage })))
const UsersPage = lazy(() => import('./pages/UsersPage').then((m) => ({ default: m.UsersPage })))
const RemoteInstallPage = lazy(() => import('./pages/RemoteInstallPage').then((m) => ({ default: m.RemoteInstallPage })))
function PageSkeleton() {
return (
<div className="row g-3">
<div className="col-md-4">
<SkeletonCard />
</div>
<div className="col-md-4">
<SkeletonCard />
</div>
<div className="col-md-4">
<SkeletonCard />
</div>
</div>
)
}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('token')
@@ -30,40 +50,182 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
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>
<ThemeProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/install"
element={
<Suspense fallback={<PageSkeleton />}>
<RemoteInstallPage />
</Suspense>
}
/>
<Route
path="/"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route
index
element={
<Suspense fallback={<PageSkeleton />}>
<DashboardPage />
</Suspense>
}
/>
<Route
path="site"
element={
<Suspense fallback={<PageSkeleton />}>
<SitePage />
</Suspense>
}
/>
<Route
path="ftp"
element={
<Suspense fallback={<PageSkeleton />}>
<FtpPage />
</Suspense>
}
/>
<Route
path="database"
element={
<Suspense fallback={<PageSkeleton />}>
<DatabasePage />
</Suspense>
}
/>
<Route
path="docker"
element={
<Suspense fallback={<PageSkeleton />}>
<DockerPage />
</Suspense>
}
/>
<Route
path="control"
element={
<Suspense fallback={<PageSkeleton />}>
<MonitorPage />
</Suspense>
}
/>
<Route
path="firewall"
element={
<Suspense fallback={<PageSkeleton />}>
<FirewallPage />
</Suspense>
}
/>
<Route
path="files"
element={
<Suspense fallback={<PageSkeleton />}>
<FilesPage />
</Suspense>
}
/>
<Route
path="node"
element={
<Suspense fallback={<PageSkeleton />}>
<NodePage />
</Suspense>
}
/>
<Route
path="logs"
element={
<Suspense fallback={<PageSkeleton />}>
<LogsPage />
</Suspense>
}
/>
<Route
path="ssl_domain"
element={
<Suspense fallback={<PageSkeleton />}>
<DomainsPage />
</Suspense>
}
/>
<Route
path="xterm"
element={
<Suspense fallback={<PageSkeleton />}>
<TerminalPage />
</Suspense>
}
/>
<Route
path="crontab"
element={
<Suspense fallback={<PageSkeleton />}>
<CrontabPage />
</Suspense>
}
/>
<Route
path="soft"
element={
<Suspense fallback={<PageSkeleton />}>
<SoftPage />
</Suspense>
}
/>
<Route
path="config"
element={
<Suspense fallback={<PageSkeleton />}>
<ConfigPage />
</Suspense>
}
/>
<Route
path="services"
element={
<Suspense fallback={<PageSkeleton />}>
<ServicesPage />
</Suspense>
}
/>
<Route
path="plugins"
element={
<Suspense fallback={<PageSkeleton />}>
<PluginsPage />
</Suspense>
}
/>
<Route
path="backup-plans"
element={
<Suspense fallback={<PageSkeleton />}>
<BackupPlansPage />
</Suspense>
}
/>
<Route
path="users"
element={
<Suspense fallback={<PageSkeleton />}>
<UsersPage />
</Suspense>
}
/>
</Route>
<Route path="/logout" element={<LogoutRedirect />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</ThemeProvider>
)
}

View File

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

View File

@@ -0,0 +1,25 @@
export function AdminAlert({
variant = 'danger',
children,
className = '',
dismissible,
onDismiss,
}: {
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info'
children: React.ReactNode
className?: string
dismissible?: boolean
onDismiss?: () => void
}) {
return (
<div
className={`alert alert-${variant} ${dismissible ? 'alert-dismissible fade show' : ''} ${className}`.trim()}
role="alert"
>
{children}
{dismissible ? (
<button type="button" className="btn-close" aria-label="Close" onClick={onDismiss} />
) : null}
</div>
)
}

View File

@@ -0,0 +1,23 @@
export function AdminButton({
children,
variant = 'primary',
size,
type = 'button',
disabled,
className = '',
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info' | 'light' | 'dark' | 'outline-primary' | 'outline-secondary' | 'outline-danger'
size?: 'sm' | 'lg'
}) {
return (
<button
type={type}
disabled={disabled}
className={`btn btn-${variant}${size ? ` btn-${size}` : ''} ${className}`.trim()}
{...rest}
>
{children}
</button>
)
}

View File

@@ -0,0 +1,30 @@
export function AdminCard({
title,
iconClass,
children,
headerExtra,
className = '',
bodyClassName = '',
}: {
title?: React.ReactNode
iconClass?: string
children: React.ReactNode
headerExtra?: React.ReactNode
className?: string
bodyClassName?: string
}) {
return (
<div className={`card flex-fill ${className}`.trim()}>
{(title || headerExtra) && (
<div className="card-header border-0 pb-0 d-flex align-items-center justify-content-between flex-wrap gap-2">
<h4 className="mb-0 d-flex align-items-center gap-2">
{iconClass ? <i className={iconClass} aria-hidden /> : null}
{title}
</h4>
{headerExtra}
</div>
)}
<div className={`card-body ${bodyClassName}`.trim()}>{children}</div>
</div>
)
}

View File

@@ -0,0 +1,47 @@
export function AdminFormField({
label,
htmlFor,
hint,
error,
children,
className = '',
}: {
label: string
htmlFor?: string
hint?: string
error?: string
children: React.ReactNode
className?: string
}) {
return (
<div className={`mb-3 ${className}`.trim()}>
<label className="form-label" htmlFor={htmlFor}>
{label}
</label>
{children}
{hint && !error ? <div className="form-text">{hint}</div> : null}
{error ? <div className="invalid-feedback d-block">{error}</div> : null}
</div>
)
}
export function AdminSelect({
id,
value,
onChange,
children,
className = '',
disabled,
}: React.SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
id={id}
className={`form-select ${className}`.trim()}
value={value}
onChange={onChange}
disabled={disabled}
>
{children}
</select>
)
}

View File

@@ -0,0 +1,214 @@
import { useEffect, useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import Dropdown from 'react-bootstrap/Dropdown'
import { menuItems } from '../../config/menu'
import { useTheme } from '../../context/ThemeContext'
import { ThemeCustomizer } from './ThemeCustomizer'
const MINI_KEY = 'yakpanel_mini_sidebar'
export function AdminLayout() {
const navigate = useNavigate()
const { theme, toggleTheme } = useTheme()
const [mobileOpen, setMobileOpen] = useState(false)
const [miniSidebar, setMiniSidebar] = useState(() => localStorage.getItem(MINI_KEY) === '1')
useEffect(() => {
localStorage.setItem(MINI_KEY, miniSidebar ? '1' : '0')
}, [miniSidebar])
useEffect(() => {
if (miniSidebar) {
document.body.classList.add('mini-sidebar')
} else {
document.body.classList.remove('mini-sidebar')
}
}, [miniSidebar])
const closeMobile = () => setMobileOpen(false)
return (
<div className={`main-wrapper ${mobileOpen ? 'slide-nav' : ''}`}>
<div className="header">
<div className={`header-left ${miniSidebar ? '' : 'active'}`}>
<NavLink to="/" className="logo logo-normal" onClick={closeMobile}>
<img src="/theme/img/logo.png" alt="YakPanel" />
<img src="/theme/img/white-logo.png" className="white-logo" alt="" />
</NavLink>
<NavLink to="/" className="logo logo-small" onClick={closeMobile}>
<img src="/theme/img/logo-small.png" alt="" />
</NavLink>
<button
type="button"
id="toggle_btn"
className={miniSidebar ? '' : 'active'}
aria-label={miniSidebar ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={() => setMiniSidebar(!miniSidebar)}
>
<i className="ti ti-arrow-bar-to-left" />
</button>
</div>
<button
type="button"
id="mobile_btn"
className="mobile_btn d-md-none btn btn-link p-0 border-0"
aria-label="Open menu"
onClick={() => setMobileOpen(!mobileOpen)}
>
<span className="bar-icon">
<span />
<span />
<span />
</span>
</button>
<div className="header-user">
<ul className="nav user-menu">
<li className="nav-item nav-search-inputs me-auto d-none d-md-block">
<div className="top-nav-search">
<form className="dropdown" onSubmit={(e) => e.preventDefault()}>
<div className="searchinputs">
<input type="search" className="form-control" placeholder="Search" aria-label="Search" />
<div className="search-addon">
<button type="submit" className="btn btn-link p-0" aria-label="Submit search">
<i className="ti ti-command" />
</button>
</div>
</div>
</form>
</div>
</li>
<li className="nav-item">
<button
type="button"
className="btn btn-link nav-link dark-mode-toggle p-0"
id="dark-mode-toggle"
aria-label="Toggle theme"
onClick={toggleTheme}
>
<i className={`ti ti-sun light-mode ${theme === 'light' ? 'active' : ''}`} />
<i className={`ti ti-moon dark-mode ${theme === 'dark' ? 'active' : ''}`} />
</button>
</li>
<li className="nav-item dropdown has-arrow main-drop">
<Dropdown align="end">
<Dropdown.Toggle as="a" className="btn btn-link nav-link user-link p-0 d-flex align-items-center text-decoration-none">
<span className="user-img">
<img src="/theme/img/profiles/avatar-14.jpg" alt="" className="rounded-circle" width={32} height={32} />
</span>
<span className="user-content d-none d-md-inline text-start ms-2">
<span className="user-name d-block fw-medium">Admin</span>
<span className="user-role text-muted small">Panel</span>
</span>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-end">
<Dropdown.Item onClick={() => navigate('/users')}>
<i className="ti ti-users me-2" />
Users
</Dropdown.Item>
<Dropdown.Item onClick={() => navigate('/config')}>
<i className="ti ti-settings me-2" />
Settings
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item onClick={() => navigate('/logout')}>
<i className="ti ti-logout me-2" />
Log out
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</li>
</ul>
</div>
<div className="dropdown mobile-user-menu d-md-none">
<Dropdown>
<Dropdown.Toggle as="a" className="nav-link dropdown-toggle">
<i className="ti ti-dots-vertical" />
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item onClick={() => { navigate('/'); closeMobile() }}>
<i className="ti ti-layout-dashboard me-2" />
Dashboard
</Dropdown.Item>
<Dropdown.Item onClick={() => navigate('/logout')}>
<i className="ti ti-logout me-2" />
Log out
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
</div>
<div className="sidebar" id="sidebar">
<div className="sidebar-inner slimscroll">
<div id="sidebar-menu" className="sidebar-menu">
<ul>
<li className="clinicdropdown">
<NavLink to="/users" onClick={closeMobile}>
<img src="/theme/img/profiles/avatar-14.jpg" className="img-fluid rounded-circle" alt="" width={40} height={40} />
<div className="user-names">
<h5>Admin</h5>
<h6>YakPanel</h6>
</div>
</NavLink>
</li>
</ul>
<ul>
<li>
<h6 className="submenu-hdr">Main</h6>
<ul>
{menuItems
.filter((m) => m.id !== 'menuLogout')
.map((item) => (
<li key={item.id}>
<NavLink
to={item.href}
onClick={closeMobile}
className={({ isActive }) => (isActive ? 'active' : undefined)}
>
<i className={item.iconClass} />
<span>{item.title}</span>
</NavLink>
</li>
))}
</ul>
</li>
</ul>
<ul>
<li>
<button
type="button"
className="btn btn-link text-start w-100 text-decoration-none text-body py-2 border-0"
onClick={() => navigate('/logout')}
>
<i className="ti ti-logout me-2" />
Log out
</button>
</li>
</ul>
</div>
</div>
</div>
<div className="page-wrapper">
<div className="content">
<Outlet />
</div>
</div>
{mobileOpen ? (
<button
type="button"
className="position-fixed top-0 start-0 w-100 h-100 bg-dark bg-opacity-25 border-0 p-0 d-md-none"
style={{ zIndex: 1040 }}
aria-label="Close menu"
onClick={closeMobile}
/>
) : null}
<ThemeCustomizer />
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useRef } from 'react'
import { Modal } from 'react-bootstrap'
export function AdminModal({
show,
onHide,
title,
children,
footer,
size,
}: {
show: boolean
onHide: () => void
title: string
children: React.ReactNode
footer?: React.ReactNode
size?: 'sm' | 'lg' | 'xl'
}) {
return (
<Modal show={show} onHide={onHide} size={size} centered scrollable>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>{children}</Modal.Body>
{footer ? <Modal.Footer>{footer}</Modal.Footer> : null}
</Modal>
)
}
/** Focus trap helper for simple confirm flows */
export function useConfirmFocus(show: boolean) {
const ref = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (show) ref.current?.focus()
}, [show])
return ref
}

View File

@@ -0,0 +1,13 @@
export function AdminTable({
children,
className = '',
responsive = true,
}: {
children: React.ReactNode
className?: string
responsive?: boolean
}) {
const table = <table className={`table table-hover ${className}`.trim()}>{children}</table>
if (responsive) return <div className="table-responsive">{table}</div>
return table
}

View File

@@ -0,0 +1,20 @@
export function EmptyState({
iconClass = 'ti ti-inbox',
title,
description,
action,
}: {
iconClass?: string
title: string
description?: string
action?: React.ReactNode
}) {
return (
<div className="text-center py-5 text-muted">
<i className={`${iconClass} display-4 d-block mb-3`} aria-hidden />
<h5 className="text-body">{title}</h5>
{description ? <p className="mb-3">{description}</p> : null}
{action}
</div>
)
}

View File

@@ -0,0 +1,44 @@
import { Link, useLocation } from 'react-router-dom'
import { titleForPath } from '../../config/routes-meta'
type Crumb = { label: string; path?: string }
export function PageHeader({
title,
breadcrumbs,
actions,
}: {
title?: string
breadcrumbs?: Crumb[]
actions?: React.ReactNode
}) {
const { pathname } = useLocation()
const pageTitle = title ?? titleForPath(pathname)
const crumbs: Crumb[] = breadcrumbs ?? [{ label: 'Home', path: '/' }, { label: pageTitle }]
return (
<div className="page-header mb-4">
<div className="row align-items-center">
<div className="col-md-6">
<h3 className="page-title">{pageTitle}</h3>
<nav aria-label="breadcrumb">
<ol className="breadcrumb mb-0">
{crumbs.map((c, i) => (
<li
key={`${c.label}-${i}`}
className={`breadcrumb-item${i === crumbs.length - 1 ? ' active' : ''}`}
{...(i === crumbs.length - 1 ? { 'aria-current': 'page' as const } : {})}
>
{c.path && i < crumbs.length - 1 ? <Link to={c.path}>{c.label}</Link> : c.label}
</li>
))}
</ol>
</nav>
</div>
{actions ? (
<div className="col-md-6 d-flex justify-content-md-end mt-2 mt-md-0">{actions}</div>
) : null}
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
export function SkeletonLine({ className = '' }: { className?: string }) {
return <div className={`placeholder-glow ${className}`.trim()}>
<span className="placeholder col-12 rounded" style={{ height: '1rem' }} />
</div>
}
export function SkeletonCard() {
return (
<div className="card">
<div className="card-body">
<SkeletonLine className="mb-2" />
<SkeletonLine className="mb-2" />
<SkeletonLine className="w-75" />
</div>
</div>
)
}
export function SkeletonTable({ rows = 5, cols = 4 }: { rows?: number; cols?: number }) {
return (
<div className="table-responsive">
<table className="table">
<thead>
<tr>
{Array.from({ length: cols }).map((_, i) => (
<th key={i}>
<span className="placeholder col-8 rounded" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, r) => (
<tr key={r}>
{Array.from({ length: cols }).map((_, c) => (
<td key={c}>
<span className="placeholder col-12 rounded" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,237 @@
import { useCallback, useEffect, useId, useState } from 'react'
import { useTheme } from '../../context/ThemeContext'
const THEME_IMG = (n: string) => `/theme/img/theme/theme-${n}.svg`
export function ThemeCustomizer() {
const {
theme,
accent,
sidebarTheme,
sidebarBg,
setTheme,
setAccent,
setSidebarTheme,
setSidebarBg,
resetTheme,
} = useTheme()
const [open, setOpen] = useState(false)
const headingId = useId()
const close = useCallback(() => setOpen(false), [])
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, close])
return (
<>
<div className="sidebar-contact">
<button
type="button"
className="toggle-theme border-0 bg-transparent p-0"
aria-expanded={open}
aria-controls="yakpanel-theme-panel"
aria-label={open ? 'Close theme settings' : 'Open theme customizer'}
onClick={() => setOpen((v) => !v)}
>
<i className={`fa fa-cog ${open ? 'fa-spin' : ''}`} aria-hidden />
</button>
</div>
<div id="yakpanel-theme-panel" className={`sidebar-themesettings ${open ? 'open' : ''}`} role="dialog" aria-modal="true" aria-labelledby={headingId}>
<div className="themesettings-header">
<h4 id={headingId}>Theme Customizer</h4>
<button type="button" className="btn btn-link p-0 text-body" aria-label="Close" onClick={close}>
<i className="ti ti-x" aria-hidden />
</button>
</div>
<div className="themesettings-inner">
<div className="themesettings-content">
<h6>Layout</h6>
<div className="row g-2">
<div className="col-6">
<ThemeImageRadio
name="yak-theme-layout"
value="light"
checked={theme === 'light'}
onChange={() => setTheme('light')}
id="yak-lightTheme"
label="Light"
imgSrc={THEME_IMG('01')}
/>
</div>
<div className="col-6">
<ThemeImageRadio
name="yak-theme-layout"
value="dark"
checked={theme === 'dark'}
onChange={() => setTheme('dark')}
id="yak-darkTheme"
label="Dark"
imgSrc={THEME_IMG('02')}
/>
</div>
</div>
</div>
<div className="themesettings-content">
<h6>Colors</h6>
<div className="theme-colorsset">
<ul className="mb-0">
{(
[
{ v: 'red' as const, id: 'yak-redColor' },
{ v: 'yellow' as const, id: 'yak-yellowColor' },
{ v: 'blue' as const, id: 'yak-blueColor' },
{ v: 'green' as const, id: 'yak-greenColor' },
] as const
).map(({ v, id }) => (
<li key={v}>
<div className="input-themeselects">
<input type="radio" name="yak-accent" id={id} value={v} checked={accent === v} onChange={() => setAccent(v)} />
<label htmlFor={id} className={`${v}-clr`} title={v}>
<span className="visually-hidden">{v}</span>
</label>
</div>
</li>
))}
</ul>
</div>
</div>
<div className="themesettings-content">
<h6>Sidebar</h6>
<div className="row g-2">
{(
[
{ v: 'light' as const, n: '03', label: 'Light' },
{ v: 'dark' as const, n: '04', label: 'Dark' },
{ v: 'blue' as const, n: '05', label: 'Blue' },
{ v: 'green' as const, n: '06', label: 'Green' },
] as const
).map(({ v, n, label }) => (
<div className="col-6" key={v}>
<ThemeImageRadio
name="yak-sidebar-style"
value={v}
checked={sidebarTheme === v}
onChange={() => setSidebarTheme(v)}
id={`yak-sidebar-${v}`}
label={label}
imgSrc={THEME_IMG(n)}
/>
</div>
))}
</div>
</div>
<div className="themesettings-content m-0 border-0">
<h6>Sidebar background</h6>
<div className="row g-2">
<div className="col-6">
<div className="input-themeselect">
<input
type="radio"
name="yak-sidebarbg"
id="yak-sidebarBgNone"
value="sidebarbgnone"
checked={sidebarBg === 'sidebarbgnone'}
onChange={() => setSidebarBg('sidebarbgnone')}
/>
<label htmlFor="yak-sidebarBgNone" className="d-flex align-items-center justify-content-center bg-body-secondary rounded" style={{ minHeight: 72 }}>
<span className="small text-muted">Default</span>
</label>
</div>
</div>
{(
[
{ v: 'sidebarbg1' as const, n: '07' },
{ v: 'sidebarbg2' as const, n: '08' },
{ v: 'sidebarbg3' as const, n: '09' },
{ v: 'sidebarbg4' as const, n: '10' },
] as const
).map(({ v, n }) => (
<div className="col-6" key={v}>
<div className="input-themeselect">
<input
type="radio"
name="yak-sidebarbg"
id={`yak-${v}`}
value={v}
checked={sidebarBg === v}
onChange={() => setSidebarBg(v)}
/>
<label htmlFor={`yak-${v}`}>
<img src={THEME_IMG(n)} alt="" />
<span className="w-100">
<span>Bg {n.slice(-1)}</span>
<span className="checkboxs-theme" />
</span>
</label>
</div>
</div>
))}
</div>
</div>
</div>
<div className="themesettings-footer">
<ul className="mb-0">
<li>
<button type="button" className="btn btn-cancel close-theme btn-light border w-100" onClick={close}>
Cancel
</button>
</li>
<li>
<button
type="button"
className="btn btn-reset btn-primary w-100"
onClick={() => {
resetTheme()
}}
>
Reset
</button>
</li>
</ul>
</div>
</div>
</>
)
}
function ThemeImageRadio<T extends string>({
name,
value,
checked,
onChange,
id,
label,
imgSrc,
}: {
name: string
value: T
checked: boolean
onChange: () => void
id: string
label: string
imgSrc: string
}) {
return (
<div className="input-themeselect">
<input type="radio" name={name} id={id} value={value} checked={checked} onChange={onChange} />
<label htmlFor={id}>
<img src={imgSrc} alt="" />
<span className="w-100">
<span>{label}</span>
<span className="checkboxs-theme" />
</span>
</label>
</div>
)
}

View File

@@ -0,0 +1,10 @@
export { AdminAlert } from './AdminAlert'
export { AdminButton } from './AdminButton'
export { AdminCard } from './AdminCard'
export { AdminFormField, AdminSelect } from './AdminFormField'
export { AdminModal, useConfirmFocus } from './AdminModal'
export { AdminTable } from './AdminTable'
export { EmptyState } from './EmptyState'
export { PageHeader } from './PageHeader'
export { SkeletonCard, SkeletonLine, SkeletonTable } from './Skeleton'
export { AdminLayout } from './AdminLayout'

View File

@@ -3,27 +3,29 @@ export interface MenuItem {
href: string
id: string
sort: number
/** Tabler icon classes, e.g. ti ti-layout-dashboard */
iconClass: string
}
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 },
{ title: 'Home', href: '/', id: 'menuHome', sort: 1, iconClass: 'ti ti-layout-dashboard' },
{ title: 'Website', href: '/site', id: 'menuSite', sort: 2, iconClass: 'ti ti-world' },
{ title: 'FTP', href: '/ftp', id: 'menuFtp', sort: 3, iconClass: 'ti ti-folder-share' },
{ title: 'Databases', href: '/database', id: 'menuDatabase', sort: 4, iconClass: 'ti ti-database' },
{ title: 'Docker', href: '/docker', id: 'menuDocker', sort: 5, iconClass: 'ti ti-brand-docker' },
{ title: 'Monitor', href: '/control', id: 'menuControl', sort: 6, iconClass: 'ti ti-heart-rate-monitor' },
{ title: 'Security', href: '/firewall', id: 'menuFirewall', sort: 7, iconClass: 'ti ti-shield-lock' },
{ title: 'Files', href: '/files', id: 'menuFiles', sort: 8, iconClass: 'ti ti-folders' },
{ title: 'Node', href: '/node', id: 'menuNode', sort: 9, iconClass: 'ti ti-brand-nodejs' },
{ title: 'Logs', href: '/logs', id: 'menuLogs', sort: 10, iconClass: 'ti ti-file-text' },
{ title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 11, iconClass: 'ti ti-world-www' },
{ title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 12, iconClass: 'ti ti-terminal-2' },
{ title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 13, iconClass: 'ti ti-clock' },
{ title: 'App Store', href: '/soft', id: 'menuSoft', sort: 14, iconClass: 'ti ti-package' },
{ title: 'Services', href: '/services', id: 'menuServices', sort: 15, iconClass: 'ti ti-server' },
{ title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 16, iconClass: 'ti ti-puzzle' },
{ title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 17, iconClass: 'ti ti-archive' },
{ title: 'Users', href: '/users', id: 'menuUsers', sort: 18, iconClass: 'ti ti-users' },
{ title: 'Settings', href: '/config', id: 'menuConfig', sort: 19, iconClass: 'ti ti-settings' },
{ title: 'Log out', href: '/logout', id: 'menuLogout', sort: 20, iconClass: 'ti ti-logout' },
]

View File

@@ -0,0 +1,28 @@
/** Breadcrumb titles per route path (match React Router path strings) */
export const routeTitleMap: Record<string, string> = {
'/': 'Dashboard',
'/site': 'Website',
'/ftp': 'FTP',
'/database': 'Databases',
'/docker': 'Docker',
'/control': 'Monitor',
'/firewall': 'Security',
'/files': 'Files',
'/node': 'Node',
'/logs': 'Logs',
'/ssl_domain': 'Domains',
'/xterm': 'Terminal',
'/crontab': 'Cron',
'/soft': 'App Store',
'/config': 'Settings',
'/services': 'Services',
'/plugins': 'Plugins',
'/backup-plans': 'Backup Plans',
'/users': 'Users',
'/login': 'Login',
'/install': 'Remote install',
}
export function titleForPath(pathname: string): string {
return routeTitleMap[pathname] || 'YakPanel'
}

View File

@@ -0,0 +1,138 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
export type ThemeMode = 'light' | 'dark'
export type AccentColor = 'red' | 'yellow' | 'blue' | 'green'
export type SidebarTheme = 'light' | 'dark' | 'blue' | 'green'
export type SidebarBg = 'sidebarbgnone' | 'sidebarbg1' | 'sidebarbg2' | 'sidebarbg3' | 'sidebarbg4'
type ThemeContextValue = {
theme: ThemeMode
accent: AccentColor
sidebarTheme: SidebarTheme
sidebarBg: SidebarBg
setAccent: (c: AccentColor) => void
setSidebarTheme: (s: SidebarTheme) => void
setSidebarBg: (b: SidebarBg) => void
toggleTheme: () => void
setTheme: (t: ThemeMode) => void
resetTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
const STORAGE_THEME = 'theme'
const STORAGE_ACCENT = 'color'
const STORAGE_SIDEBAR = 'sidebarTheme'
const STORAGE_SIDEBAR_BG = 'sidebarBg'
function readSidebarTheme(): SidebarTheme {
const s = localStorage.getItem(STORAGE_SIDEBAR) as SidebarTheme | null
if (s === 'light' || s === 'dark' || s === 'blue' || s === 'green') return s
return 'light'
}
function readSidebarBg(): SidebarBg {
const s = localStorage.getItem(STORAGE_SIDEBAR_BG) as SidebarBg | null
if (
s === 'sidebarbgnone' ||
s === 'sidebarbg1' ||
s === 'sidebarbg2' ||
s === 'sidebarbg3' ||
s === 'sidebarbg4'
) {
return s
}
return 'sidebarbgnone'
}
function applyHtmlAttributes(
theme: ThemeMode,
accent: AccentColor,
sidebar: SidebarTheme,
sidebarBg: SidebarBg
) {
const root = document.documentElement
root.setAttribute('data-theme', theme)
root.setAttribute('data-sidebar', sidebar)
root.setAttribute('data-color', accent)
root.setAttribute('data-sidebar-bg', sidebarBg)
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>(() => {
const s = localStorage.getItem(STORAGE_THEME) as ThemeMode | null
if (s === 'dark' || s === 'light') return s
const legacy = localStorage.getItem('darkMode')
if (legacy === 'enabled') return 'dark'
return 'light'
})
const [accent, setAccentState] = useState<AccentColor>(() => {
const s = localStorage.getItem(STORAGE_ACCENT) as AccentColor | null
if (s === 'red' || s === 'yellow' || s === 'blue' || s === 'green') return s
return 'red'
})
const [sidebarTheme, setSidebarThemeState] = useState<SidebarTheme>(readSidebarTheme)
const [sidebarBg, setSidebarBgState] = useState<SidebarBg>(readSidebarBg)
useEffect(() => {
applyHtmlAttributes(theme, accent, sidebarTheme, sidebarBg)
localStorage.setItem(STORAGE_THEME, theme)
localStorage.setItem(STORAGE_ACCENT, accent)
localStorage.setItem(STORAGE_SIDEBAR, sidebarTheme)
localStorage.setItem(STORAGE_SIDEBAR_BG, sidebarBg)
if (theme === 'dark') localStorage.setItem('darkMode', 'enabled')
else localStorage.removeItem('darkMode')
}, [theme, accent, sidebarTheme, sidebarBg])
const setTheme = useCallback((t: ThemeMode) => setThemeState(t), [])
const setAccent = useCallback((c: AccentColor) => setAccentState(c), [])
const setSidebarTheme = useCallback((s: SidebarTheme) => setSidebarThemeState(s), [])
const setSidebarBg = useCallback((b: SidebarBg) => setSidebarBgState(b), [])
const toggleTheme = useCallback(() => {
setThemeState((prev) => (prev === 'light' ? 'dark' : 'light'))
}, [])
const resetTheme = useCallback(() => {
setThemeState('light')
setAccentState('red')
setSidebarThemeState('light')
setSidebarBgState('sidebarbgnone')
localStorage.removeItem('darkMode')
}, [])
const value = useMemo(
() => ({
theme,
accent,
sidebarTheme,
sidebarBg,
setAccent,
setSidebarTheme,
setSidebarBg,
toggleTheme,
setTheme,
resetTheme,
}),
[
theme,
accent,
sidebarTheme,
sidebarBg,
setAccent,
setSidebarTheme,
setSidebarBg,
toggleTheme,
setTheme,
resetTheme,
]
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}

View File

@@ -1,9 +1,5 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Global entry after Bootstrap + theme; keep minimal app-only rules */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
-webkit-font-smoothing: antialiased;
}

View File

@@ -1,8 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
import '@fortawesome/fontawesome-free/css/all.min.css'
import '@tabler/icons-webfont/dist/tabler-icons.min.css'
import './styles/theme-style.css'
import './styles/yakpanel-overrides.css'
import './index.css'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import {
apiRequest,
listBackupPlans,
@@ -7,7 +8,7 @@ import {
deleteBackupPlan,
runScheduledBackups,
} from '../api/client'
import { Plus, Trash2, Play, Pencil } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface BackupPlanRecord {
id: number
@@ -137,130 +138,146 @@ export function BackupPlansPage() {
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>
if (loading) {
return (
<>
<PageHeader title="Backup Plans" />
<p className="text-secondary">Loading</p>
</>
)
}
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>
<>
<PageHeader
title="Backup Plans"
actions={
<div className="d-flex flex-wrap gap-2">
<AdminButton variant="primary" disabled={runLoading} onClick={handleRunScheduled}>
{runLoading ? (
<span className="spinner-border spinner-border-sm me-1" role="status" />
) : (
<i className="ti ti-player-play me-1" aria-hidden />
)}
{runLoading ? 'Running…' : 'Run Scheduled'}
</AdminButton>
<AdminButton variant="primary" onClick={() => setShowCreate(true)}>
<i className="ti ti-plus me-1" aria-hidden />
Add Plan
</AdminButton>
</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>
)}
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<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.
{runResults && runResults.length > 0 ? (
<div className="card mb-3">
<div className="card-body py-3">
<h6 className="mb-2">Last run results</h6>
<ul className="mb-0 small list-unstyled">
{runResults.map((r, i) => (
<li key={i}>
{r.plan}: {r.status === 'ok' ? '✓' : r.status === 'skipped' ? '⊘' : '✗'} {r.msg || ''}
</li>
))}
</ul>
</div>
</div>
) : null}
<p className="small text-secondary mb-3">
Schedule automated backups. Add a cron entry (e.g. <code>0 * * * *</code> hourly) to call{' '}
<code>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">
<div className="card">
<AdminTable>
<thead>
<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>
<th>Name</th>
<th>Type</th>
<th>Target</th>
<th>Schedule</th>
<th>Enabled</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{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 colSpan={6} className="p-0">
<EmptyState
title="No backup plans"
description='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>{p.name}</td>
<td>{p.plan_type}</td>
<td>{getTargetName(p)}</td>
<td>
<code className="small">{p.schedule}</code>
</td>
<td>{p.enabled ? 'Yes' : 'No'}</td>
<td className="text-end">
<button
type="button"
className="btn btn-link btn-sm text-primary p-1"
title="Edit"
onClick={() => handleEdit(p)}
>
<i className="ti ti-pencil" aria-hidden />
</button>
<button
type="button"
className="btn btn-link btn-sm text-danger p-1"
title="Delete"
onClick={() => handleDelete(p.id, p.name)}
>
<i className="ti ti-trash" aria-hidden />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</AdminTable>
</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>
<Modal show={!!editPlan} onHide={() => setEditPlan(null)} centered>
<Modal.Header closeButton>
<Modal.Title>Edit Backup Plan</Modal.Title>
</Modal.Header>
{editPlan ? (
<form onSubmit={handleUpdate}>
<Modal.Body>
<div className="mb-3">
<label className="form-label">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"
className="form-control"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Type</label>
<div className="mb-3">
<label className="form-label">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"
className="form-select"
>
<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>
<div className="mb-3">
<label className="form-label">Target</label>
<select
name="edit_target_id"
defaultValue={
@@ -271,7 +288,7 @@ export function BackupPlansPage() {
: 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"
className="form-select"
required
>
{editPlanType === 'site'
@@ -287,108 +304,102 @@ export function BackupPlansPage() {
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schedule (cron)</label>
<div className="mb-3">
<label className="form-label">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"
className="form-control"
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 className="form-check">
<input name="edit_enabled" type="checkbox" defaultChecked={editPlan.enabled} className="form-check-input" id="edit_enabled" />
<label className="form-check-label" htmlFor="edit_enabled">
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>
)}
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => setEditPlan(null)}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary">
Update
</AdminButton>
</Modal.Footer>
</form>
) : null}
</Modal>
{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>
<Modal show={showCreate} onHide={() => setShowCreate(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Add Backup Plan</Modal.Title>
</Modal.Header>
<form onSubmit={handleCreate}>
<Modal.Body>
<div className="mb-3">
<label className="form-label">Name</label>
<input
name="name"
type="text"
placeholder="Daily site backup"
className="form-control"
required
/>
</div>
<div className="mb-3">
<label className="form-label">Type</label>
<select
name="plan_type"
value={createPlanType}
onChange={(e) => setCreatePlanType(e.target.value as 'site' | 'database')}
className="form-select"
>
<option value="site">Site</option>
<option value="database">Database</option>
</select>
</div>
<div className="mb-3">
<label className="form-label">Target</label>
<select name="target_id" className="form-select" 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 className="mb-3">
<label className="form-label">Schedule (cron)</label>
<input name="schedule" type="text" placeholder="0 2 * * *" className="form-control" required />
<div className="form-text">e.g. 0 2 * * * = daily at 2am, 0 */6 * * * = every 6 hours</div>
</div>
<div className="form-check">
<input name="enabled" type="checkbox" defaultChecked className="form-check-input" id="plan_enabled" />
<label className="form-check-label" htmlFor="plan_enabled">
Enabled
</label>
</div>
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => setShowCreate(false)}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create'}
</AdminButton>
</Modal.Footer>
</form>
</Modal>
</>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { apiRequest, changePassword, testEmail } from '../api/client'
import { Save, Key, Mail } from 'lucide-react'
import { PageHeader, AdminCard, AdminAlert, AdminButton } from '../components/admin'
interface PanelConfig {
panel_port: number
@@ -108,138 +108,164 @@ export function ConfigPage() {
.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
if (loading) {
return (
<>
<PageHeader title="Settings" />
<p className="text-secondary">Loading</p>
</>
)
}
if (!config) {
return (
<>
<PageHeader title="Settings" />
{error ? <AdminAlert>{error}</AdminAlert> : null}
</>
)
}
return (
<div>
<h1 className="text-2xl font-bold mb-6 text-gray-800 dark:text-white">Settings</h1>
<>
<PageHeader title="Settings" />
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<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" />
<form onSubmit={handleSave} className="row g-3">
<div className="col-12 col-xl-6">
<AdminCard title="Panel" iconClass="ti ti-adjustments-horizontal">
<div className="mb-3">
<label className="form-label">Panel Port</label>
<input name="panel_port" type="number" defaultValue={config.panel_port} className="form-control" />
</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 className="mb-3">
<label className="form-label">WWW Root</label>
<input name="www_root" type="text" defaultValue={config.www_root} className="form-control" />
</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 className="mb-3">
<label className="form-label">Setup Path</label>
<input name="setup_path" type="text" defaultValue={config.setup_path} className="form-control" />
</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">
<div className="mb-0">
<label className="form-label">Webserver</label>
<select name="webserver_type" defaultValue={config.webserver_type} className="form-select">
<option value="nginx">Nginx</option>
<option value="apache">Apache</option>
<option value="openlitespeed">OpenLiteSpeed</option>
</select>
</div>
</div>
</AdminCard>
</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 className="col-12 col-xl-6">
<AdminCard title="Notifications (Email)" iconClass="ti ti-mail">
<div className="mb-3">
<label className="form-label">Email To</label>
<input
name="email_to"
type="email"
defaultValue={configKeys.email_to}
placeholder="admin@example.com"
className="form-control"
/>
</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 className="mb-3">
<label className="form-label">SMTP Server</label>
<input
name="smtp_server"
type="text"
defaultValue={configKeys.smtp_server}
placeholder="smtp.gmail.com"
className="form-control"
/>
</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 className="mb-3">
<label className="form-label">SMTP Port</label>
<input name="smtp_port" type="number" defaultValue={configKeys.smtp_port || '587'} className="form-control" />
</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 className="mb-3">
<label className="form-label">SMTP User</label>
<input name="smtp_user" type="text" defaultValue={configKeys.smtp_user} className="form-control" />
</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 className="mb-3">
<label className="form-label">SMTP Password</label>
<input
name="smtp_password"
type="password"
placeholder={configKeys.smtp_password ? '•••••••• (leave blank to keep)' : 'Optional'}
className="form-control"
/>
</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" />
<p className="small text-secondary">Used for panel alerts (e.g. backup completion, security warnings).</p>
<AdminButton type="button" variant="warning" className="mt-2" onClick={handleTestEmail}>
<i className="ti ti-mail me-1" aria-hidden />
Send Test Email
</button>
{testEmailResult && (
<p className={`text-sm ${testEmailResult.startsWith('Failed') ? 'text-red-600' : 'text-green-600'}`}>
</AdminButton>
{testEmailResult ? (
<p
className={`small mt-2 mb-0 ${testEmailResult.startsWith('Failed') ? 'text-danger' : 'text-success'}`}
>
{testEmailResult}
</p>
)}
</div>
) : null}
</AdminCard>
</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>
<div className="col-12 col-xl-6">
<AdminCard title="MySQL" iconClass="ti ti-database">
<div className="mb-0">
<label className="form-label">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"
className="form-control"
/>
<p className="mt-1 text-xs text-gray-500">Used to create/drop MySQL databases. Leave blank to keep current.</p>
<p className="small text-secondary mt-2 mb-0">Used to create/drop MySQL databases. Leave blank to keep current.</p>
</div>
</div>
</AdminCard>
</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" />
<div className="col-12">
<AdminButton type="submit" variant="primary">
<i className="ti ti-device-floppy me-1" aria-hidden />
Save
</button>
{saved && <span className="text-green-600 dark:text-green-400 text-sm">Saved</span>}
</AdminButton>
{saved ? <span className="text-success ms-3 small">Saved</span> : null}
</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 className="col-12 col-xl-6 mt-3 px-0">
<AdminCard title="Change Password" iconClass="ti ti-key">
<form onSubmit={handleChangePassword}>
{pwError ? <AdminAlert className="mb-3">{pwError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">Current Password</label>
<input name="old_password" type="password" className="form-control" required />
</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 className="mb-3">
<label className="form-label">New Password</label>
<input name="new_password" type="password" minLength={6} className="form-control" required />
</div>
<div className="mb-3">
<label className="form-label">Confirm New Password</label>
<input name="confirm_password" type="password" minLength={6} className="form-control" required />
</div>
<AdminButton type="submit" variant="warning">
<i className="ti ti-key me-1" aria-hidden />
Change Password
</AdminButton>
{pwSaved ? <span className="text-success ms-3 small">Password changed</span> : null}
</form>
</AdminCard>
</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 className="alert alert-secondary mt-4 mb-0">
<p className="mb-1">
<strong>App:</strong> {config.app_name} v{config.app_version}
</p>
<p className="mb-0 small">Note: Some settings require a panel restart to take effect.</p>
</div>
</div>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import { apiRequest, applyCrontab } from '../api/client'
import { Plus, Trash2, Edit2, Zap } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface CronJob {
id: number
@@ -90,116 +91,177 @@ export function CrontabPage() {
.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>
if (loading) {
return (
<>
<PageHeader title="Cron" />
<p className="text-secondary">Loading</p>
</>
)
}
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>
<>
<PageHeader
title="Cron"
actions={
<div className="d-flex flex-wrap gap-2">
<AdminButton variant="success" disabled={applying || jobs.length === 0} onClick={handleApply}>
{applying ? (
<span className="spinner-border spinner-border-sm me-1" role="status" />
) : (
<i className="ti ti-bolt me-1" aria-hidden />
)}
<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>
{applying ? 'Applying…' : 'Apply to System'}
</AdminButton>
<AdminButton
variant="primary"
onClick={() => {
setEditingId(null)
setEditJob(null)
setShowForm(true)
}}
>
<i className="ti ti-plus me-1" aria-hidden />
Add Cron
</AdminButton>
</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">
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<Modal show={showForm} onHide={() => { setShowForm(false); setEditingId(null); setEditJob(null) }} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>{editingId ? 'Edit Cron Job' : 'Create Cron Job'}</Modal.Title>
</Modal.Header>
<form key={editingId ?? 'new'} onSubmit={handleSubmit}>
<Modal.Body>
{formError ? <AdminAlert className="mb-3">{formError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">Name (optional)</label>
<input
id="cron-name"
name="name"
type="text"
placeholder="My task"
defaultValue={editJob?.name}
className="form-control"
/>
</div>
<div className="mb-3">
<label className="form-label">Schedule (cron)</label>
<select
className="form-select mb-2"
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="form-control"
required
/>
</div>
<div className="mb-0">
<label className="form-label">Command</label>
<textarea
id="cron-execstr"
name="execstr"
rows={3}
placeholder="/usr/bin/php /www/wwwroot/script.php"
defaultValue={editJob?.execstr}
className="form-control"
required
/>
</div>
</Modal.Body>
<Modal.Footer>
<AdminButton
type="button"
variant="secondary"
onClick={() => {
setShowForm(false)
setEditingId(null)
setEditJob(null)
}}
>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={saving}>
{saving ? 'Saving…' : editingId ? 'Update' : 'Create'}
</AdminButton>
</Modal.Footer>
</form>
</Modal>
<div className="alert alert-warning small mb-3">
Jobs are stored in the panel. Click &quot;Apply to System&quot; to sync them to the system crontab (root).
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<div className="card">
<AdminTable>
<thead>
<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>
<th>Name</th>
<th>Schedule</th>
<th>Command</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{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>
<td colSpan={4} className="p-0">
<EmptyState title="No cron jobs" description='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>{j.name || ''}</td>
<td>
<code className="small">{j.schedule}</code>
</td>
<td className="small font-monospace text-truncate" style={{ maxWidth: 280 }} title={j.execstr}>
{j.execstr}
</td>
<td className="text-end">
<button
type="button"
className="btn btn-link btn-sm text-primary p-1"
title="Edit"
onClick={() => handleEdit(j)}
>
<i className="ti ti-edit" aria-hidden />
</button>
<button
type="button"
className="btn btn-link btn-sm text-danger p-1"
title="Delete"
onClick={() => handleDelete(j.id)}
>
<i className="ti ti-trash" aria-hidden />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</AdminTable>
</div>
</div>
</>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { getDashboardStats } from '../api/client'
import { Server, Database, Folder, HardDrive, Cpu, MemoryStick } from 'lucide-react'
import { PageHeader, AdminCard, AdminAlert } from '../components/admin'
interface Stats {
site_count: number
@@ -29,84 +29,84 @@ export function DashboardPage() {
if (error) {
return (
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700">
{error}
</div>
<>
<PageHeader />
<AdminAlert variant="danger">{error}</AdminAlert>
</>
)
}
if (!stats) {
return <div className="text-gray-500">Loading...</div>
return (
<>
<PageHeader />
<div className="placeholder-glow">
<span className="placeholder col-12 rounded" style={{ height: '8rem' }} />
</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}
/>
<>
<PageHeader />
<div className="row g-3 mb-4">
<div className="col-md-4 d-flex">
<StatCard iconClass="ti ti-world" title="Websites" value={stats.site_count} />
</div>
<div className="col-md-4 d-flex">
<StatCard iconClass="ti ti-folder-share" title="FTP Accounts" value={stats.ftp_count} />
</div>
<div className="col-md-4 d-flex">
<StatCard iconClass="ti ti-database" title="Databases" value={stats.database_count} />
</div>
</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 className="row g-3">
<div className="col-md-4 d-flex">
<StatCard iconClass="ti ti-cpu" title="CPU" value={`${stats.system.cpu_percent}%`} />
</div>
<div className="col-md-4 d-flex">
<StatCard
iconClass="ti ti-device-desktop"
title="Memory"
value={`${stats.system.memory_percent}%`}
subtitle={`${stats.system.memory_used_mb} / ${stats.system.memory_total_mb} MB`}
/>
</div>
<div className="col-md-4 d-flex">
<StatCard
iconClass="ti ti-database-export"
title="Disk"
value={`${stats.system.disk_percent}%`}
subtitle={`${stats.system.disk_used_gb} / ${stats.system.disk_total_gb} GB`}
/>
</div>
</div>
</div>
</>
)
}
function StatCard({
icon,
iconClass,
title,
value,
subtitle,
}: {
icon: React.ReactNode
iconClass: string
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>
<AdminCard className="border-0 shadow-sm" bodyClassName="d-flex align-items-center gap-3">
<span className="avatar avatar-md bg-primary-transparent text-primary rounded-circle d-flex align-items-center justify-content-center flex-shrink-0">
<i className={`${iconClass} fs-4`} aria-hidden />
</span>
<div>
<p className="text-muted mb-0 small">{title}</p>
<p className="fs-4 fw-semibold mb-0">{value}</p>
{subtitle ? <p className="text-muted small mb-0">{subtitle}</p> : null}
</div>
</div>
</AdminCard>
)
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import {
apiRequest,
createDatabaseBackup,
@@ -7,7 +8,7 @@ import {
downloadDatabaseBackup,
updateDatabasePassword,
} from '../api/client'
import { Plus, Trash2, Archive, Download, RotateCcw, Key } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface DbRecord {
id: number
@@ -133,256 +134,227 @@ export function DatabasePage() {
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>
if (loading) {
return (
<>
<PageHeader title="Databases" />
<div className="text-center py-5 text-muted">Loading</div>
</>
)
}
if (error && !databases.length) {
return (
<>
<PageHeader title="Databases" />
<AdminAlert variant="danger">{error}</AdminAlert>
</>
)
}
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>
<>
<PageHeader
title="Databases"
actions={
<AdminButton onClick={() => setShowCreate(true)}>
<i className="ti ti-plus me-1" />
Add Database
</AdminButton>
}
/>
{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>
)}
{error ? <AdminAlert variant="warning">{error}</AdminAlert> : null}
<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 ? (
<Modal show={showCreate} onHide={() => setShowCreate(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Create Database</Modal.Title>
</Modal.Header>
<form onSubmit={handleCreate}>
<Modal.Body>
{creatingError ? <AdminAlert variant="danger">{creatingError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">Database Name</label>
<input name="name" type="text" placeholder="mydb" className="form-control" required />
</div>
<div className="mb-3">
<label className="form-label">Username</label>
<input name="username" type="text" placeholder="dbuser" className="form-control" required />
</div>
<div className="mb-3">
<label className="form-label">Password</label>
<input name="password" type="password" className="form-control" required />
</div>
<div className="mb-3">
<label className="form-label">Type</label>
<select name="db_type" className="form-select">
<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>
<div className="form-text">
MySQL, PostgreSQL, MongoDB: create, delete, backup/restore. Password change supported for those types.
</div>
</div>
<div className="mb-0">
<label className="form-label">Note (optional)</label>
<input name="ps" type="text" placeholder="My database" className="form-control" />
</div>
</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn-light" onClick={() => setShowCreate(false)}>
Cancel
</button>
<button type="submit" disabled={creating} className="btn btn-primary">
{creating ? 'Creating…' : 'Create'}
</button>
</Modal.Footer>
</form>
</Modal>
<div className="card shadow-sm border-0">
<div className="card-body p-0">
<AdminTable>
<thead className="table-light">
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
No databases. Click "Add Database" to create one.
</td>
<th>Name</th>
<th>Username</th>
<th>Type</th>
<th>Note</th>
<th className="text-end">Actions</th>
</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">
</thead>
<tbody>
{databases.length === 0 ? (
<tr>
<td colSpan={5} className="p-0">
<EmptyState title="No databases" description='Click "Add Database" to create one.' />
</td>
</tr>
) : (
databases.map((d) => (
<tr key={d.id}>
<td className="align-middle">{d.name}</td>
<td className="align-middle">{d.username}</td>
<td className="align-middle">{d.db_type}</td>
<td className="align-middle text-muted">{d.ps || '—'}</td>
<td className="align-middle text-end">
{(d.db_type === 'MySQL' || d.db_type === 'PostgreSQL' || d.db_type === 'MongoDB') && (
<button
type="button"
onClick={() => openBackupModal(d.id)}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
className="btn btn-sm btn-outline-primary me-1"
title="Backup"
>
<Archive className="w-4 h-4" />
<i className="ti ti-archive" />
</button>
)}
{(d.db_type === 'MySQL' || d.db_type === 'PostgreSQL' || d.db_type === 'MongoDB') && (
<button
type="button"
onClick={() => setChangePwId(d.id)}
className="p-2 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded"
className="btn btn-sm btn-outline-warning me-1"
title="Change password"
>
<Key className="w-4 h-4" />
<i className="ti ti-key" />
</button>
)}
<button
type="button"
onClick={() => handleDelete(d.id, d.name)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
className="btn btn-sm btn-outline-danger"
title="Delete"
>
<Trash2 className="w-4 h-4" />
<i className="ti ti-trash" />
</button>
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</td>
</tr>
))
)}
</tbody>
</AdminTable>
</div>
</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>
<Modal show={backupDbId != null} onHide={() => setBackupDbId(null)} centered size="lg">
<Modal.Header closeButton>
<Modal.Title>Database Backup</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="mb-3">
<AdminButton onClick={handleCreateBackup} disabled={backupLoading}>
<i className="ti ti-archive me-1" />
{backupLoading ? 'Creating…' : 'Create Backup'}
</AdminButton>
</div>
</div>
)}
<h6 className="text-muted small text-uppercase">Existing backups</h6>
{backups.length === 0 ? (
<p className="text-muted small mb-0">No backups yet</p>
) : (
<ul className="list-group list-group-flush">
{backups.map((b) => (
<li key={b.filename} className="list-group-item d-flex align-items-center justify-content-between gap-2">
<code className="small flex-grow-1 text-truncate">{b.filename}</code>
<span className="text-muted small flex-shrink-0">{formatSize(b.size)}</span>
<span className="flex-shrink-0">
<button
type="button"
className="btn btn-sm btn-link p-1"
onClick={() => handleDownloadBackup(b.filename)}
title="Download"
>
<i className="ti ti-download" />
</button>
<button
type="button"
className="btn btn-sm btn-link p-1 text-warning"
onClick={() => handleRestore(b.filename)}
disabled={backupLoading}
title="Restore"
>
<i className="ti ti-restore" />
</button>
</span>
</li>
))}
</ul>
)}
</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn-light" onClick={() => setBackupDbId(null)}>
Close
</button>
</Modal.Footer>
</Modal>
{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 />
<Modal show={changePwId != null} onHide={() => setChangePwId(null)} centered>
<Modal.Header closeButton>
<Modal.Title>Change Database Password</Modal.Title>
</Modal.Header>
{changePwId != null ? (
<form onSubmit={(e) => handleChangePassword(e, changePwId)}>
<Modal.Body>
{pwError ? <AdminAlert variant="danger">{pwError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">New Password</label>
<input name="new_password" type="password" minLength={6} className="form-control" 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 className="mb-0">
<label className="form-label">Confirm Password</label>
<input name="confirm_password" type="password" minLength={6} className="form-control" 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>
</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn-light" onClick={() => setChangePwId(null)}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Update
</button>
</Modal.Footer>
</form>
) : null}
</Modal>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import { apiRequest, listDockerContainers, listDockerImages, dockerPull, dockerRun } from '../api/client'
import { Play, Square, RotateCw, Loader2, Plus, Download } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface Container {
id: string
@@ -18,6 +19,11 @@ interface DockerImage {
size: string
}
function ActionSpinner({ show }: { show: boolean }) {
if (!show) return null
return <span className="spinner-border spinner-border-sm" role="status" />
}
export function DockerPage() {
const [containers, setContainers] = useState<Container[]>([])
const [images, setImages] = useState<DockerImage[]>([])
@@ -34,10 +40,7 @@ export function DockerPage() {
const load = () => {
setLoading(true)
Promise.all([
listDockerContainers(),
listDockerImages(),
])
Promise.all([listDockerContainers(), listDockerImages()])
.then(([contData, imgData]) => {
setContainers(contData.containers || [])
setImages(imgData.images || [])
@@ -106,181 +109,169 @@ export function DockerPage() {
.finally(() => setPulling(false))
}
if (loading) return <div className="text-gray-500">Loading...</div>
if (loading) {
return (
<>
<PageHeader title="Docker" />
<p className="text-secondary">Loading</p>
</>
)
}
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>
<>
<PageHeader
title="Docker"
actions={
<div className="d-flex flex-wrap align-items-center gap-2">
<div className="d-flex gap-1 align-items-center">
<input
value={pullImage}
onChange={(e) => setPullImage(e.target.value)}
placeholder="nginx:latest"
className="form-control form-control-sm"
style={{ width: '10rem' }}
/>
<AdminButton variant="warning" size="sm" onClick={handlePull} disabled={pulling || !pullImage.trim()}>
{pulling ? <ActionSpinner show /> : <i className="ti ti-download me-1" aria-hidden />}
Pull
</AdminButton>
</div>
<AdminButton variant="primary" size="sm" onClick={() => setShowRun(true)}>
<i className="ti ti-plus me-1" aria-hidden />
Run Container
</AdminButton>
<AdminButton variant="secondary" size="sm" onClick={load}>
<i className="ti ti-rotate-clockwise me-1" aria-hidden />
Refresh
</AdminButton>
</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>
)}
<Modal show={showRun} onHide={() => setShowRun(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Run Container</Modal.Title>
</Modal.Header>
<form onSubmit={handleRun}>
<Modal.Body>
<div className="mb-3">
<label className="form-label">Image</label>
<input
value={runImage}
onChange={(e) => setRunImage(e.target.value)}
placeholder="nginx:latest"
className="form-control"
required
/>
</div>
<div className="mb-3">
<label className="form-label">Name (optional)</label>
<input
value={runName}
onChange={(e) => setRunName(e.target.value)}
placeholder="my-nginx"
className="form-control"
/>
</div>
<div className="mb-0">
<label className="form-label">Ports (optional, e.g. 80:80 or 8080:80)</label>
<input
value={runPorts}
onChange={(e) => setRunPorts(e.target.value)}
placeholder="80:80"
className="form-control"
/>
</div>
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => setShowRun(false)}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={running}>
{running ? 'Starting…' : 'Run'}
</AdminButton>
</Modal.Footer>
</form>
</Modal>
{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 ? (
<AdminAlert variant="warning" className="mb-3">
{error}
</div>
)}
</AdminAlert>
) : null}
<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">
<div className="card">
<AdminTable>
<thead>
<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>
<th>Container</th>
<th>Image</th>
<th>Status</th>
<th>Ports</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{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 colSpan={5} className="p-0">
<EmptyState
title="No containers"
description="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>
<tr key={c.id}>
<td className="font-monospace small">{c.names || c.id}</td>
<td>{c.image}</td>
<td>
<span className={isRunning(c.status) ? 'text-success' : 'text-secondary'}>{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 className="small text-truncate" style={{ maxWidth: 200 }}>
{c.ports || ''}
</td>
<td className="px-4 py-2 text-right">
<span className="flex gap-1 justify-end">
<td className="text-end">
<span className="d-inline-flex gap-1 justify-content-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"
type="button"
className="btn btn-link btn-sm text-warning p-1"
title="Restart"
disabled={actionId === c.id_full}
onClick={() => handleRestart(c.id_full)}
>
{actionId === c.id_full ? (
<Loader2 className="w-4 h-4 animate-spin" />
<ActionSpinner show />
) : (
<RotateCw className="w-4 h-4" />
<i className="ti ti-rotate-clockwise" aria-hidden />
)}
</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"
type="button"
className="btn btn-link btn-sm text-danger p-1"
title="Stop"
disabled={actionId === c.id_full}
onClick={() => handleStop(c.id_full)}
>
<Square className="w-4 h-4" />
<i className="ti ti-square" aria-hidden />
</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"
type="button"
className="btn btn-link btn-sm text-success p-1"
title="Start"
disabled={actionId === c.id_full}
onClick={() => handleStart(c.id_full)}
>
{actionId === c.id_full ? (
<Loader2 className="w-4 h-4 animate-spin" />
<ActionSpinner show />
) : (
<Play className="w-4 h-4" />
<i className="ti ti-player-play" aria-hidden />
)}
</button>
)}
@@ -290,46 +281,50 @@ export function DockerPage() {
))
)}
</tbody>
</table>
</AdminTable>
</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>
<h2 className="h5 mt-4 mb-3">Images</h2>
<div className="card">
{images.length === 0 ? (
<div className="card-body">
<p className="text-secondary text-center mb-0">No images. Pull one above.</p>
</div>
) : (
<AdminTable>
<thead>
<tr>
<th>Repository</th>
<th>Tag</th>
<th>Size</th>
<th className="text-end">Action</th>
</tr>
</thead>
<tbody>
{images.map((img) => (
<tr key={img.id}>
<td className="font-monospace">{img.repository}</td>
<td>{img.tag}</td>
<td>{img.size}</td>
<td className="text-end">
<button
type="button"
className="btn btn-link btn-sm text-primary p-1"
title="Run"
onClick={() => {
setRunImage(`${img.repository}:${img.tag}`)
setShowRun(true)
}}
>
<i className="ti ti-player-play" aria-hidden />
</button>
</td>
</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>
))}
</tbody>
</AdminTable>
)}
</div>
</div>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import { apiRequest } from '../api/client'
import { Shield, Loader2 } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert } from '../components/admin'
interface Domain {
id: number
@@ -66,150 +67,136 @@ export function DomainsPage() {
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>
if (loading) {
return (
<>
<PageHeader title="Domains & SSL" />
<p className="text-secondary">Loading</p>
</>
)
}
return (
<div>
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Domains & SSL</h1>
<>
<PageHeader title="Domains & SSL" />
<div className="mb-6 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-800 dark:text-amber-200">
<p>Request Let&apos;s Encrypt certificates for your site domains. Requires certbot and nginx configured for the domain.</p>
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<div className="alert alert-warning small mb-4">
Request Let&apos;s Encrypt certificates for your site domains. Requires certbot and nginx configured for the domain.
</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 className="row g-4">
<div className="col-lg-6">
<div className="card h-100">
<div className="card-header">Domains (from sites)</div>
<div className="list-group list-group-flush overflow-auto" style={{ maxHeight: '20rem' }}>
{domains.length === 0 ? (
<div className="list-group-item text-secondary text-center py-4">No domains. Add a site first.</div>
) : (
domains.map((d) => (
<div
key={d.id}
className="list-group-item d-flex align-items-center justify-content-between gap-2 flex-wrap"
>
<div className="small">
<span className="font-monospace">{d.name}</span>
{d.port !== '80' ? <span className="text-secondary ms-1">:{d.port}</span> : null}
<span className="text-secondary ms-2">({d.site_name})</span>
</div>
<div>
{hasCert(d.name) ? (
<span className="text-success small">
<i className="ti ti-shield-check me-1" aria-hidden />
Cert
</span>
) : (
<AdminButton
variant="outline-primary"
size="sm"
disabled={!!requesting}
onClick={() => {
setRequestDomain(d)
setRequestEmail('')
}}
>
{requesting === d.name ? (
<span className="spinner-border spinner-border-sm" role="status" />
) : (
'Request SSL'
)}
</AdminButton>
)}
</div>
</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>
<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 className="col-lg-6">
<div className="card h-100">
<div className="card-header">Certificates</div>
<div className="list-group list-group-flush overflow-auto" style={{ maxHeight: '20rem' }}>
{certificates.length === 0 ? (
<div className="list-group-item text-secondary text-center py-4">No certificates yet</div>
) : (
certificates.map((c) => (
<div key={c.name} className="list-group-item d-flex align-items-center gap-2">
<i className="ti ti-shield-check text-success flex-shrink-0" aria-hidden />
<span className="font-monospace small text-break">{c.name}</span>
</div>
))
)}
</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"
/>
<Modal show={!!requestDomain} onHide={() => setRequestDomain(null)} centered>
<Modal.Header closeButton>
<Modal.Title>Request SSL for {requestDomain?.name}</Modal.Title>
</Modal.Header>
{requestDomain ? (
<form onSubmit={handleRequestCert}>
<Modal.Body>
<div className="mb-3">
<label className="form-label">Domain</label>
<input type="text" value={requestDomain.name} readOnly className="form-control-plaintext border rounded px-3 py-2 bg-body-secondary" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Webroot (site path)
</label>
<div className="mb-3">
<label className="form-label">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"
className="form-control-plaintext border rounded px-3 py-2 bg-body-secondary"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email (for Let&apos;s Encrypt)
</label>
<div className="mb-0">
<label className="form-label">Email (for Let&apos;s Encrypt)</label>
<input
type="email"
value={requestEmail}
onChange={(e) => setRequestEmail(e.target.value)}
placeholder="admin@example.com"
className="w-full px-4 py-2 border rounded-lg bg-white dark:bg-gray-700"
className="form-control"
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>
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => setRequestDomain(null)}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={!!requesting}>
{requesting ? 'Requesting…' : 'Request'}
</AdminButton>
</Modal.Footer>
</form>
) : null}
</Modal>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, useRef } from 'react'
import Modal from 'react-bootstrap/Modal'
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'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface FileItem {
name: string
@@ -150,205 +151,196 @@ export function FilesPage() {
const canGoBack = breadcrumbs.length > 0
return (
<div>
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Files</h1>
<>
<PageHeader title="Files" />
<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" />
<div className="d-flex flex-wrap align-items-center gap-2 mb-3">
<AdminButton variant="secondary" size="sm" onClick={handleBack} disabled={!canGoBack}>
<i className="ti ti-arrow-left me-1" aria-hidden />
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" />
</AdminButton>
<input ref={fileInputRef} type="file" className="d-none" onChange={handleUpload} />
<AdminButton variant="success" size="sm" onClick={() => setShowMkdir(true)}>
<i className="ti ti-folder-plus me-1" aria-hidden />
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" />}
</AdminButton>
<AdminButton variant="primary" size="sm" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? (
<span className="spinner-border spinner-border-sm me-1" role="status" />
) : (
<i className="ti ti-upload me-1" aria-hidden />
)}
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>
</AdminButton>
<code className="small bg-body-secondary px-2 py-1 rounded ms-auto text-break">Path: {path}</code>
</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}
<Modal show={showMkdir} onHide={() => { setShowMkdir(false); setMkdirName('') }} centered>
<Modal.Header closeButton>
<Modal.Title>New Folder</Modal.Title>
</Modal.Header>
<form onSubmit={handleMkdir}>
<Modal.Body>
<input
value={mkdirName}
onChange={(e) => setMkdirName(e.target.value)}
placeholder="Folder name"
className="form-control"
autoFocus
/>
</div>
</div>
)}
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => { setShowMkdir(false); setMkdirName('') }}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="success">
Create
</AdminButton>
</Modal.Footer>
</form>
</Modal>
{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>
)}
<Modal show={!!editingFile} onHide={() => setEditingFile(null)} fullscreen="lg-down" size="lg">
<Modal.Header closeButton>
<Modal.Title className="text-break small font-monospace">{editingFile}</Modal.Title>
</Modal.Header>
<Modal.Body className="d-flex flex-column p-0" style={{ minHeight: 400 }}>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="form-control font-monospace small flex-grow-1 rounded-0 border-0"
style={{ minHeight: 400 }}
spellCheck={false}
/>
</Modal.Body>
<Modal.Footer>
<AdminButton variant="secondary" onClick={() => setEditingFile(null)}>
Cancel
</AdminButton>
<AdminButton variant="primary" onClick={handleSaveEdit} disabled={savingEdit}>
{savingEdit ? 'Saving…' : 'Save'}
</AdminButton>
</Modal.Footer>
</Modal>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<div className="card">
{loading ? (
<div className="p-8 flex justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
<div className="card-body text-center py-5">
<span className="spinner-border text-secondary" role="status" />
</div>
) : (
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<AdminTable>
<thead>
<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>
<th>Name</th>
<th>Size</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-gray-500">
Empty directory
<td colSpan={3} className="p-0">
<EmptyState title="Empty directory" description="Upload files or create a folder." />
</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">
<tr key={item.name}>
<td>
<button
type="button"
onClick={() => handleNavigate(item)}
className="flex items-center gap-2 text-left w-full hover:text-blue-600 dark:hover:text-blue-400"
className="btn btn-link text-start text-decoration-none p-0 d-inline-flex align-items-center gap-2"
>
{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>
<i className={`ti ${item.is_dir ? 'ti-folder text-warning' : 'ti-file text-secondary'}`} aria-hidden />
<span>{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>
) : (
<>
<td className="text-secondary">{item.is_dir ? '—' : formatSize(item.size)}</td>
<td className="text-end">
{renaming?.name === item.name ? (
<span className="d-inline-flex gap-1 align-items-center flex-wrap justify-content-end">
<input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
className="form-control form-control-sm"
style={{ width: '8rem' }}
autoFocus
/>
<button type="button" className="btn btn-link btn-sm text-success p-1" title="Save" onClick={handleRename}>
<i className="ti ti-check" aria-hidden />
</button>
<button
type="button"
className="btn btn-link btn-sm p-1"
onClick={() => {
setRenaming(null)
setRenameValue('')
}}
>
Cancel
</button>
</span>
) : (
<span className="d-inline-flex gap-1 justify-content-end">
<button
type="button"
className="btn btn-link btn-sm text-secondary p-1"
title="Rename"
onClick={() => {
setRenaming(item)
setRenameValue(item.name)
}}
>
<i className="ti ti-pencil" aria-hidden />
</button>
{!item.is_dir && canEdit(item.name) ? (
<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"
type="button"
className="btn btn-link btn-sm text-warning p-1"
title="Edit"
onClick={() => handleEdit(item)}
>
<Pencil className="w-4 h-4" />
<i className="ti ti-edit" aria-hidden />
</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>
)}
) : null}
{!item.is_dir ? (
<button
onClick={() => handleDelete(item)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
title="Delete"
type="button"
className="btn btn-link btn-sm text-primary p-1"
title="Download"
disabled={downloading === item.name}
onClick={() => handleDownload(item)}
>
<Trash2 className="w-4 h-4" />
{downloading === item.name ? (
<span className="spinner-border spinner-border-sm" role="status" />
) : (
<i className="ti ti-download" aria-hidden />
)}
</button>
</>
)}
</span>
) : null}
<button
type="button"
className="btn btn-link btn-sm text-danger p-1"
title="Delete"
onClick={() => handleDelete(item)}
>
<i className="ti ti-trash" aria-hidden />
</button>
</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</AdminTable>
)}
</div>
</div>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import { apiRequest, applyFirewallRules } from '../api/client'
import { Plus, Trash2, Zap } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface FirewallRule {
id: number
@@ -73,147 +74,133 @@ export function FirewallPage() {
.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>
if (loading) {
return (
<>
<PageHeader title="Security / Firewall" />
<p className="text-secondary">Loading</p>
</>
)
}
return (
<div>
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Security / Firewall</h1>
<div className="flex gap-2">
<button
onClick={handleApply}
disabled={applying || rules.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-400 text-white rounded-lg font-medium"
>
<Zap className="w-4 h-4" />
{applying ? 'Applying...' : 'Apply to UFW'}
</button>
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
>
<Plus className="w-4 h-4" />
Add Rule
</button>
</div>
</div>
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-200 text-sm">
Rules are stored in the panel. Click &quot;Apply to UFW&quot; to run <code className="font-mono">ufw allow/deny</code> for each rule.
</div>
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4 text-gray-800 dark:text-white">Add Firewall Rule</h2>
<form onSubmit={handleCreate} className="space-y-4">
{creatingError && (
<div className="p-2 rounded bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-sm">
{creatingError}
</div>
<>
<PageHeader
title="Security / Firewall"
actions={
<div className="d-flex flex-wrap gap-2">
<AdminButton variant="success" disabled={applying || rules.length === 0} onClick={handleApply}>
{applying ? (
<span className="spinner-border spinner-border-sm me-1" role="status" />
) : (
<i className="ti ti-bolt me-1" aria-hidden />
)}
<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>
{applying ? 'Applying…' : 'Apply to UFW'}
</AdminButton>
<AdminButton variant="primary" onClick={() => setShowCreate(true)}>
<i className="ti ti-plus me-1" aria-hidden />
Add Rule
</AdminButton>
</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">
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<div className="alert alert-warning small mb-3">
Rules are stored in the panel. Click &quot;Apply to UFW&quot; to run <code className="font-monospace">ufw allow/deny</code> for each rule.
</div>
<Modal show={showCreate} onHide={() => setShowCreate(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Add Firewall Rule</Modal.Title>
</Modal.Header>
<form onSubmit={handleCreate}>
<Modal.Body>
{creatingError ? <AdminAlert className="mb-3">{creatingError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">Port</label>
<input
name="port"
type="text"
placeholder="80 or 80-90 or 80,443"
className="form-control"
required
/>
</div>
<div className="mb-3">
<label className="form-label">Protocol</label>
<select name="protocol" className="form-select">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div className="mb-3">
<label className="form-label">Action</label>
<select name="action" className="form-select">
<option value="accept">Accept</option>
<option value="drop">Drop</option>
<option value="reject">Reject</option>
</select>
</div>
<div className="mb-0">
<label className="form-label">Note (optional)</label>
<input name="ps" type="text" placeholder="HTTP" className="form-control" />
</div>
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => setShowCreate(false)}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={creating}>
{creating ? 'Adding…' : 'Add'}
</AdminButton>
</Modal.Footer>
</form>
</Modal>
<div className="card">
<AdminTable>
<thead>
<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>
<th>Port</th>
<th>Protocol</th>
<th>Action</th>
<th>Note</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{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 colSpan={5} className="p-0">
<EmptyState title="No rules" description='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">
<td className="font-monospace">{r.port}</td>
<td>{r.protocol}</td>
<td>{r.action}</td>
<td>{r.ps || ''}</td>
<td className="text-end">
<button
onClick={() => handleDelete(r.id, r.port)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
type="button"
className="btn btn-link btn-sm text-danger p-1"
title="Delete"
onClick={() => handleDelete(r.id, r.port)}
>
<Trash2 className="w-4 h-4" />
<i className="ti ti-trash" aria-hidden />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</AdminTable>
</div>
</div>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import { apiRequest, updateFtpPassword } from '../api/client'
import { Plus, Trash2, Key } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface FtpAccount {
id: number
@@ -84,174 +85,159 @@ export function FtpPage() {
.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 (loading) {
return (
<>
<PageHeader title="FTP" />
<div className="text-center py-5 text-muted">Loading</div>
</>
)
}
if (error) {
return (
<>
<PageHeader title="FTP" />
<AdminAlert variant="danger">{error}</AdminAlert>
</>
)
}
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>
<>
<PageHeader
title="FTP"
actions={
<AdminButton onClick={() => setShowCreate(true)}>
<i className="ti ti-plus me-1" />
Add FTP
</AdminButton>
}
/>
<div className="alert alert-secondary" role="note">
FTP accounts use Pure-FTPd (pure-pw). Path must be under www root. Install:{' '}
<code>apt install pure-ftpd pure-ftpd-common</code>
</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>
<Modal show={showCreate} onHide={() => setShowCreate(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Create FTP Account</Modal.Title>
</Modal.Header>
<form onSubmit={handleCreate}>
<Modal.Body>
{creatingError ? <AdminAlert variant="danger">{creatingError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">Username</label>
<input
name="name"
type="text"
placeholder="ftpuser"
className="form-control"
required
/>
</div>
<div className="mb-3">
<label className="form-label">Password</label>
<input name="password" type="password" className="form-control" required />
</div>
<div className="mb-3">
<label className="form-label">Path</label>
<input name="path" type="text" placeholder="/www/wwwroot" className="form-control" required />
</div>
<div className="mb-0">
<label className="form-label">Note (optional)</label>
<input name="ps" type="text" placeholder="My FTP" className="form-control" />
</div>
</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn-light" onClick={() => setShowCreate(false)}>
Cancel
</button>
<button type="submit" disabled={creating} className="btn btn-primary">
{creating ? 'Creating…' : 'Create'}
</button>
</Modal.Footer>
</form>
</Modal>
{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 ? (
<div className="card shadow-sm border-0">
<div className="card-body p-0">
<AdminTable>
<thead className="table-light">
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
No FTP accounts. Click "Add FTP" to create one.
</td>
<th>Name</th>
<th>Path</th>
<th>Note</th>
<th className="text-end">Actions</th>
</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>
</thead>
<tbody>
{accounts.length === 0 ? (
<tr>
<td colSpan={4} className="p-0">
<EmptyState title="No FTP accounts" description='Click "Add FTP" to create one.' />
</td>
</tr>
))
)}
</tbody>
</table>
) : (
accounts.map((a) => (
<tr key={a.id}>
<td className="align-middle">{a.name}</td>
<td className="align-middle">{a.path}</td>
<td className="align-middle text-muted">{a.ps || '—'}</td>
<td className="align-middle text-end">
<button
type="button"
onClick={() => setChangePwId(a.id)}
className="btn btn-sm btn-outline-warning me-1"
title="Change password"
>
<i className="ti ti-key" />
</button>
<button
type="button"
onClick={() => handleDelete(a.id, a.name)}
className="btn btn-sm btn-outline-danger"
title="Delete"
>
<i className="ti ti-trash" />
</button>
</td>
</tr>
))
)}
</tbody>
</AdminTable>
</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 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 />
<Modal show={changePwId != null} onHide={() => setChangePwId(null)} centered>
<Modal.Header closeButton>
<Modal.Title>Change FTP Password</Modal.Title>
</Modal.Header>
{changePwId != null ? (
<form onSubmit={(e) => handleChangePassword(e, changePwId)}>
<Modal.Body>
{pwError ? <AdminAlert variant="danger">{pwError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">New Password</label>
<input name="new_password" type="password" minLength={6} className="form-control" 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 className="mb-0">
<label className="form-label">Confirm Password</label>
<input name="confirm_password" type="password" minLength={6} className="form-control" 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>
</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn-light" onClick={() => setChangePwId(null)}>
Cancel
</button>
<button type="submit" className="btn btn-primary">
Update
</button>
</Modal.Footer>
</form>
) : null}
</Modal>
</>
)
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { login } from '../api/client'
export function LoginPage() {
@@ -24,57 +24,58 @@ export function LoginPage() {
}
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 className="account-content position-relative min-vh-100 d-flex align-items-center justify-content-center p-4">
<div className="card shadow-lg border-0" style={{ maxWidth: 420, width: '100%' }}>
<div className="card-body p-4 p-md-5">
<div className="text-center mb-4">
<img src="/theme/img/logo.png" alt="YakPanel" className="mb-3" height={40} />
<h4 className="fw-bold">YakPanel</h4>
<p className="text-muted small mb-0">Sign in to continue</p>
</div>
<form onSubmit={handleSubmit}>
{error ? (
<div className="alert alert-danger" role="alert">
{error}
</div>
) : null}
<div className="mb-3">
<label className="form-label">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="form-control"
required
autoComplete="username"
/>
</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 className="mb-3">
<label className="form-label">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="form-control"
required
autoComplete="current-password"
/>
</div>
<button type="submit" disabled={loading} className="btn btn-primary w-100 py-2">
{loading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden />
Signing in
</>
) : (
'Login'
)}
</button>
</form>
<p className="text-center text-muted small mt-3 mb-0">Default: admin / admin</p>
<p className="text-center small mt-2 mb-0">
<Link to="/install">Remote SSH install (optional)</Link>
</p>
</div>
</div>
</div>
)

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { listLogs, readLog } from '../api/client'
import { Folder, File, ArrowLeft, Loader2, RefreshCw } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert } from '../components/admin'
interface LogItem {
name: string
@@ -80,113 +80,97 @@ export function LogsPage() {
const canGoBack = breadcrumbs.length > 0
return (
<div>
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Logs</h1>
<>
<PageHeader title="Logs" />
<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" />
<div className="d-flex flex-wrap align-items-center gap-2 mb-3">
<AdminButton variant="secondary" size="sm" onClick={handleBack} disabled={!canGoBack}>
<i className="ti ti-arrow-left me-1" aria-hidden />
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>
</AdminButton>
<code className="small bg-body-secondary px-2 py-1 rounded text-break">Path: {path || '/'}</code>
</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>
)}
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<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 className="row g-3">
<div className="col-lg-6">
<div className="card h-100">
<div className="card-header small fw-medium">Log files</div>
{loading ? (
<div className="card-body text-center py-5">
<span className="spinner-border text-secondary" role="status" />
</div>
) : (
<pre className="font-mono text-xs text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-all">
{fileContent || '(empty)'}
</pre>
<div className="list-group list-group-flush overflow-auto" style={{ maxHeight: 500 }}>
{items.length === 0 ? (
<div className="list-group-item text-secondary text-center py-4">Empty directory</div>
) : (
items.map((item) => (
<button
key={item.name}
type="button"
className="list-group-item list-group-item-action d-flex gap-2 align-items-center"
onClick={() => handleNavigate(item)}
>
<i className={`ti flex-shrink-0 ${item.is_dir ? 'ti-folder text-warning' : 'ti-file text-secondary'}`} aria-hidden />
<span className="text-truncate">{item.name}</span>
{!item.is_dir ? (
<span className="small text-secondary ms-auto flex-shrink-0">{formatSize(item.size)}</span>
) : null}
</button>
))
)}
</div>
)}
</div>
</div>
<div className="col-lg-6">
<div className="card h-100 d-flex flex-column" style={{ minHeight: 400 }}>
<div className="card-header d-flex align-items-center justify-content-between gap-2 flex-wrap">
<span className="small fw-medium text-truncate">{viewingFile || 'Select a log file'}</span>
{viewingFile ? (
<div className="d-flex align-items-center gap-2 flex-shrink-0">
<label className="small text-secondary mb-0">Lines:</label>
<select
value={tailLines}
onChange={(e) => setTailLines(Number(e.target.value))}
className="form-select form-select-sm"
style={{ width: 'auto' }}
>
<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>
<AdminButton variant="light" size="sm" onClick={handleRefreshFile} disabled={fileLoading} title="Refresh">
{fileLoading ? (
<span className="spinner-border spinner-border-sm" role="status" />
) : (
<i className="ti ti-refresh" aria-hidden />
)}
</AdminButton>
</div>
) : null}
</div>
<div className="card-body flex-grow-1 overflow-auto">
{!viewingFile ? (
<p className="text-secondary small mb-0">Click a log file to view</p>
) : fileLoading ? (
<div className="text-center py-5">
<span className="spinner-border text-secondary" role="status" />
</div>
) : (
<pre className="font-monospace small mb-0 text-break" style={{ whiteSpace: 'pre-wrap' }}>
{fileContent || '(empty)'}
</pre>
)}
</div>
</div>
</div>
</div>
</div>
</>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { apiRequest, getMonitorProcesses, getMonitorNetwork } from '../api/client'
import { Cpu, HardDrive, MemoryStick, Activity, Network } from 'lucide-react'
import { PageHeader, AdminAlert, AdminTable } from '../components/admin'
interface SystemStats {
cpu_percent: number
@@ -59,141 +59,170 @@ export function MonitorPage() {
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>
if (error && !stats) {
return (
<>
<PageHeader title="Monitor" />
<AdminAlert>{error}</AdminAlert>
</>
)
}
if (!stats) {
return (
<>
<PageHeader title="Monitor" />
<p className="text-secondary">Loading</p>
</>
)
}
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>
<>
<PageHeader title="Monitor" />
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<p className="small text-secondary mb-3">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 className="row g-3 mb-3">
<div className="col-md-4">
<StatCard
iconClass="ti ti-cpu"
title="CPU"
value={`${stats.cpu_percent}%`}
subtitle="Usage"
percent={stats.cpu_percent}
/>
</div>
<div className="col-md-4">
<StatCard
iconClass="ti ti-device-sd-card"
title="Memory"
value={`${stats.memory_used_mb} / ${stats.memory_total_mb} MB`}
subtitle={`${stats.memory_percent}% used`}
percent={stats.memory_percent}
/>
</div>
<div className="col-md-4">
<StatCard
iconClass="ti ti-database"
title="Disk"
value={`${stats.disk_used_gb} / ${stats.disk_total_gb} GB`}
subtitle={`${stats.disk_percent}% used`}
percent={stats.disk_percent}
/>
</div>
</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>
{network ? (
<div className="card mb-3">
<div className="card-body">
<div className="d-flex align-items-center gap-2 mb-3">
<i className="ti ti-network fs-5" aria-hidden />
<span className="fw-medium">Network I/O</span>
</div>
<div>
<span className="text-gray-500">Received</span>
<p className="font-mono font-medium">{network.bytes_recv_mb} MB</p>
<div className="row g-3 small">
<div className="col-6">
<span className="text-secondary d-block">Sent</span>
<span className="font-monospace fw-medium">{network.bytes_sent_mb} MB</span>
</div>
<div className="col-6">
<span className="text-secondary d-block">Received</span>
<span className="font-monospace fw-medium">{network.bytes_recv_mb} MB</span>
</div>
</div>
</div>
</div>
)}
) : null}
<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 className="card">
<div className="card-header d-flex align-items-center gap-2">
<i className="ti ti-cpu" aria-hidden />
<span className="fw-medium">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">
<div className="table-responsive" style={{ maxHeight: '20rem' }}>
<AdminTable responsive={false}>
<thead className="sticky-top bg-body-secondary">
<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>
<th className="small">PID</th>
<th className="small">Name</th>
<th className="small">User</th>
<th className="small text-end">CPU %</th>
<th className="small text-end">Mem %</th>
<th className="small">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{processes.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-4 text-center text-gray-500 text-sm">No process data</td>
<td colSpan={6} className="text-center text-secondary small py-3">
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 key={p.pid} className="small">
<td className="font-monospace">{p.pid}</td>
<td className="text-truncate" style={{ maxWidth: 120 }} title={p.name}>
{p.name}
</td>
<td>{p.username}</td>
<td className="text-end font-monospace">{p.cpu_percent}%</td>
<td className="text-end font-monospace">{p.memory_percent}%</td>
<td className="text-secondary">{p.status}</td>
</tr>
))
)}
</tbody>
</table>
</AdminTable>
</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 className="alert alert-warning small mt-3 mb-0">
<div className="d-flex align-items-center gap-2 fw-medium mb-1">
<i className="ti ti-activity" aria-hidden />
Live monitoring
</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>
System metrics, processes, and network stats are polled every 3 seconds.
</div>
</div>
</>
)
}
function StatCard({
icon,
iconClass,
title,
value,
subtitle,
percent,
}: {
icon: React.ReactNode
iconClass: string
title: string
value: string
subtitle: string
percent: number
}) {
const barColor = percent > 90 ? 'bg-red-500' : percent > 70 ? 'bg-amber-500' : 'bg-blue-500'
const barVariant = percent > 90 ? 'bg-danger' : percent > 70 ? 'bg-warning' : 'bg-primary'
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 className="card h-100">
<div className="card-body">
<div className="d-flex align-items-center gap-3 mb-3">
<div className="p-3 rounded bg-primary-subtle text-primary">
<i className={`${iconClass} fs-2`} aria-hidden />
</div>
<div>
<p className="small text-secondary mb-0">{title}</p>
<p className="h5 mb-0">{value}</p>
<p className="small text-secondary mb-0">{subtitle}</p>
</div>
</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 className="progress" style={{ height: 6 }}>
<div
className={`progress-bar ${barVariant}`}
role="progressbar"
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
</div>
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full ${barColor} transition-all duration-500`}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import { apiRequest, nodeAddProcess } from '../api/client'
import { Play, Square, RotateCw, Trash2, Loader2, Plus } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface Pm2Process {
id: number
@@ -14,7 +15,7 @@ interface Pm2Process {
}
function formatUptime(ms: number): string {
if (!ms || ms < 0) return '-'
if (!ms || ms < 0) return ''
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
@@ -24,10 +25,15 @@ function formatUptime(ms: number): string {
}
function formatMemory(bytes: number): string {
if (!bytes) return '-'
if (!bytes) return ''
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
function ActionSpinner({ show }: { show: boolean }) {
if (!show) return null
return <span className="spinner-border spinner-border-sm" role="status" />
}
export function NodePage() {
const [processes, setProcesses] = useState<Pm2Process[]>([])
const [nodeVersion, setNodeVersion] = useState<string | null>(null)
@@ -109,181 +115,163 @@ export function NodePage() {
const isOnline = (status: string) =>
status?.toLowerCase() === 'online' || status?.toLowerCase() === 'launching'
if (loading) return <div className="text-gray-500">Loading...</div>
if (loading) {
return (
<>
<PageHeader title="Node.js" />
<p className="text-secondary">Loading</p>
</>
)
}
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>
<>
<PageHeader
title="Node.js"
actions={
<div className="d-flex flex-wrap align-items-center gap-2">
{nodeVersion ? <span className="small text-secondary me-2">Node: {nodeVersion}</span> : null}
<AdminButton variant="primary" size="sm" onClick={() => setShowAdd(true)}>
<i className="ti ti-plus me-1" aria-hidden />
Add Process
</AdminButton>
<AdminButton variant="secondary" size="sm" onClick={load}>
<i className="ti ti-rotate-clockwise me-1" aria-hidden />
Refresh
</AdminButton>
</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">
<Modal show={showAdd} onHide={() => setShowAdd(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Add PM2 Process</Modal.Title>
</Modal.Header>
<form onSubmit={handleAdd}>
<Modal.Body>
<div className="mb-3">
<label className="form-label">Script path</label>
<input
value={addScript}
onChange={(e) => setAddScript(e.target.value)}
placeholder="/www/wwwroot/app.js"
className="form-control"
required
/>
</div>
<div className="mb-0">
<label className="form-label">Name (optional)</label>
<input
value={addName}
onChange={(e) => setAddName(e.target.value)}
placeholder="myapp"
className="form-control"
/>
</div>
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => setShowAdd(false)}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={adding}>
{adding ? 'Starting…' : 'Start'}
</AdminButton>
</Modal.Footer>
</form>
</Modal>
{error ? (
<AdminAlert variant="warning" className="mb-3">
{error}
</div>
)}
</AdminAlert>
) : null}
<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 className="alert alert-secondary small mb-3">
PM2 process manager. Install with: <code className="font-monospace">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">
<div className="card">
<AdminTable>
<thead>
<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>
<th>Name</th>
<th>Status</th>
<th>PID</th>
<th>Uptime</th>
<th>Memory</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{processes.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
No PM2 processes. Click &quot;Add Process&quot; or run <code className="font-mono">pm2 start app.js</code>.
<td colSpan={6} className="p-0">
<EmptyState
title="No PM2 processes"
description='Click "Add Process" or run pm2 start app.js on the server.'
/>
</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>
<tr key={p.id}>
<td className="font-monospace">{p.name}</td>
<td>
<span className={isOnline(p.status) ? 'text-success' : 'text-secondary'}>{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">
<td>{p.pid || ''}</td>
<td>{formatUptime(p.uptime)}</td>
<td>{formatMemory(p.memory)}</td>
<td className="text-end">
<span className="d-inline-flex gap-1 justify-content-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"
type="button"
className="btn btn-link btn-sm text-warning p-1"
title="Restart"
disabled={actionId === p.id}
onClick={() => handleRestart(p.id)}
>
{actionId === p.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
<ActionSpinner show />
) : (
<RotateCw className="w-4 h-4" />
<i className="ti ti-rotate-clockwise" aria-hidden />
)}
</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"
type="button"
className="btn btn-link btn-sm text-danger p-1"
title="Stop"
disabled={actionId === p.id}
onClick={() => handleStop(p.id)}
>
<Square className="w-4 h-4" />
<i className="ti ti-square" aria-hidden />
</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"
type="button"
className="btn btn-link btn-sm text-success p-1"
title="Start"
disabled={actionId === p.id}
onClick={() => handleStart(p.id)}
>
{actionId === p.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
<ActionSpinner show />
) : (
<Play className="w-4 h-4" />
<i className="ti ti-player-play" aria-hidden />
)}
</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"
type="button"
className="btn btn-link btn-sm text-secondary p-1"
title="Delete"
disabled={actionId === p.id}
onClick={() => handleDelete(p.id, p.name)}
>
<Trash2 className="w-4 h-4" />
<i className="ti ti-trash" aria-hidden />
</button>
</span>
</td>
@@ -291,8 +279,8 @@ export function NodePage() {
))
)}
</tbody>
</table>
</AdminTable>
</div>
</div>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import { apiRequest, addPluginFromUrl, deletePlugin } from '../api/client'
import { Puzzle, Check, Plus, Trash2 } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert } from '../components/admin'
interface Plugin {
id: string
@@ -54,89 +55,106 @@ export function PluginsPage() {
.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 (loading) {
return (
<>
<PageHeader
title="Plugins"
actions={
<AdminButton variant="primary" disabled>
<i className="ti ti-plus me-1" aria-hidden />
Add from URL
</AdminButton>
}
/>
<p className="text-secondary">Loading</p>
</>
)
}
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>
<>
<PageHeader
title="Plugins"
actions={
<AdminButton variant="primary" onClick={() => setShowAdd(true)}>
<i className="ti ti-plus me-1" aria-hidden />
Add from URL
</AdminButton>
}
/>
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<div className="alert alert-secondary small mb-4">
Built-in extensions and third-party plugins. Add plugins from a JSON manifest URL (must include <code>id</code>,{' '}
<code>name</code>, and optionally <code>version</code>, <code>desc</code>).
</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>
<Modal show={showAdd} onHide={() => { setShowAdd(false); setAddError('') }} centered>
<Modal.Header closeButton>
<Modal.Title>Add Plugin from URL</Modal.Title>
</Modal.Header>
<form onSubmit={handleAdd}>
<Modal.Body>
{addError ? <AdminAlert className="mb-3">{addError}</AdminAlert> : null}
<label className="form-label">Manifest URL</label>
<input
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
placeholder="https://example.com/plugin.json"
className="form-control"
required
/>
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => { setShowAdd(false); setAddError('') }}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={adding}>
{adding ? 'Adding…' : 'Add'}
</AdminButton>
</Modal.Footer>
</form>
</Modal>
{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">
<div className="row g-3">
{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 key={p.id} className="col-md-6 col-xl-4">
<div className="card h-100">
<div className="card-body d-flex gap-3">
<i className="ti ti-puzzle text-primary fs-2 flex-shrink-0" aria-hidden />
<div className="min-w-0 flex-grow-1">
<div className="d-flex flex-wrap align-items-center gap-2 mb-1">
<h3 className="h6 mb-0">{p.name}</h3>
{p.enabled ? (
<span className="badge bg-success-subtle text-success small">
<i className="ti ti-check me-1" aria-hidden />
Enabled
</span>
) : null}
{!p.builtin ? (
<button
type="button"
className="btn btn-link btn-sm text-danger p-0 ms-auto"
title="Remove"
onClick={() => handleDelete(p.id)}
>
<i className="ti ti-trash" aria-hidden />
</button>
) : null}
</div>
<p className="small text-secondary mb-1">{p.desc}</p>
<p className="small text-muted mb-0">
v{p.version}
{p.builtin ? ' (built-in)' : ''}
</p>
</div>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">{p.desc}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">v{p.version}{p.builtin ? ' (built-in)' : ''}</p>
</div>
</div>
))}
</div>
</div>
</>
)
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { AdminAlert, AdminButton } from '../components/admin'
type InstallerConfig = { enabled: boolean; default_install_url: string }
@@ -96,198 +97,209 @@ export function RemoteInstallPage() {
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 className="min-vh-100 d-flex align-items-center justify-content-center bg-body-secondary">
<p className="text-secondary mb-0">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 className="min-vh-100 d-flex align-items-center justify-content-center bg-body-secondary p-3">
<AdminAlert>{cfgError}</AdminAlert>
</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">
<div className="min-vh-100 d-flex align-items-center justify-content-center bg-body-secondary p-3">
<div className="card shadow-sm w-100" style={{ maxWidth: 520 }}>
<div className="card-body p-4">
<h1 className="h4 mb-3">Remote SSH installer disabled</h1>
<p className="small text-secondary mb-3">
Enable it on the API server with environment variable{' '}
<code>ENABLE_REMOTE_INSTALLER=true</code> and restart the backend. Prefer SSH keys; exposing this endpoint
increases risk.
</p>
<p className="small text-secondary mb-3">
One-liner on the target server (as root):
</p>
<pre className="bg-dark text-success small p-3 rounded mb-3 mb-0 overflow-auto">
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>
</pre>
<Link to="/login" className="btn btn-link btn-sm px-0 mt-3">
Panel login
</Link>
</div>
</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="min-vh-100 bg-body-secondary py-4 px-3">
<div className="container" style={{ maxWidth: 900 }}>
<AdminAlert variant="warning" className="mb-3">
<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}.
</AdminAlert>
<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">
<div className="card shadow-sm">
<div className="card-body p-4">
<h1 className="h4 mb-2">Remote install (SSH)</h1>
<p className="small text-secondary mb-4 mb-0">
Runs the published <code>install.sh</code> on the target via SSH (same as shell one-liner).
</p>
<hr />
<form onSubmit={handleSubmit} className="mt-3">
{formError ? <AdminAlert className="mb-3">{formError}</AdminAlert> : null}
<div className="row g-3 mb-3">
<div className="col-md-8">
<label className="form-label">Host</label>
<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-----"
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
className="form-control"
placeholder="203.0.113.50"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Key passphrase (optional)
</label>
<div className="col-md-4">
<label className="form-label">SSH port</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"
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
className="form-control"
min={1}
max={65535}
/>
</div>
</>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SSH password</label>
</div>
<div className="mb-3">
<label className="form-label">SSH username</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"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="form-control"
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 className="mb-3">
<span className="form-label d-block">Authentication</span>
<div className="d-flex gap-4">
<div className="form-check">
<input
className="form-check-input"
type="radio"
name="auth"
id="auth-key"
checked={authType === 'key'}
onChange={() => setAuthType('key')}
/>
<label className="form-check-label" htmlFor="auth-key">
Private key
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="radio"
name="auth"
id="auth-pw"
checked={authType === 'password'}
onChange={() => setAuthType('password')}
/>
<label className="form-check-label" htmlFor="auth-pw">
Password
</label>
</div>
</div>
</div>
{authType === 'key' ? (
<>
<div className="mb-3">
<label className="form-label">Private key (PEM)</label>
<textarea
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
rows={6}
className="form-control font-monospace small"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
required
/>
</div>
<div className="mb-3">
<label className="form-label">Key passphrase (optional)</label>
<input
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
className="form-control"
/>
</div>
</>
) : (
<div className="mb-3">
<label className="form-label">SSH password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="form-control"
required
/>
</div>
)}
<div className="mb-3">
<label className="form-label">Install script URL (https only)</label>
<input
type="url"
value={installUrl}
onChange={(e) => setInstallUrl(e.target.value)}
className="form-control small"
/>
</div>
<AdminButton type="submit" variant="primary" className="w-100" disabled={running || !cfg?.enabled || !cfg}>
{running ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" />
Running
</>
) : (
'Start remote install'
)}
</AdminButton>
</form>
</div>
</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>
)}
{log.length > 0 ? (
<div className="card bg-dark text-light mt-3">
<div className="card-body p-3">
<pre
ref={logRef}
className="small font-monospace mb-0 overflow-auto text-wrap"
style={{ maxHeight: '24rem' }}
>
{log.join('\n')}
</pre>
{exitCode !== null ? (
<p className="small border-secondary border-top pt-2 mt-2 mb-0">
Exit code: <strong>{exitCode}</strong>
</p>
) : null}
</div>
</div>
)}
) : null}
<p className="text-center text-sm text-gray-500">
<Link to="/login" className="text-blue-600 hover:underline">
Panel login
</Link>
<p className="text-center small text-secondary mt-3 mb-0">
<Link to="/login">Panel login</Link>
</p>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { apiRequest } from '../api/client'
import { Play, Square, RotateCw, Loader2 } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert, AdminTable } from '../components/admin'
interface Service {
id: string
@@ -9,6 +9,11 @@ interface Service {
status: string
}
function ActionSpinner({ show }: { show: boolean }) {
if (!show) return null
return <span className="spinner-border spinner-border-sm" role="status" />
}
export function ServicesPage() {
const [services, setServices] = useState<Service[]>([])
const [loading, setLoading] = useState(true)
@@ -53,84 +58,91 @@ export function ServicesPage() {
const isActive = (status: string) => status === 'active' || status === 'activating'
if (loading) return <div className="text-gray-500">Loading...</div>
if (loading) {
return (
<>
<PageHeader title="Services" />
<p className="text-secondary">Loading</p>
</>
)
}
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>
<>
<PageHeader
title="Services"
actions={
<AdminButton variant="secondary" size="sm" onClick={load}>
<i className="ti ti-rotate-clockwise me-1" aria-hidden />
Refresh
</AdminButton>
}
/>
{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>
)}
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<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">
<div className="alert alert-secondary small mb-3">
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">
<div className="card">
<AdminTable>
<thead>
<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>
<th>Service</th>
<th>Unit</th>
<th>Status</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{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>
<tr key={s.id}>
<td>{s.name}</td>
<td className="font-monospace small">{s.unit}</td>
<td>
<span className={isActive(s.status) ? 'text-success' : 'text-secondary'}>{s.status}</span>
</td>
<td className="px-4 py-2 text-right">
<span className="flex gap-1 justify-end">
<td className="text-end">
<span className="d-inline-flex gap-1 justify-content-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"
type="button"
className="btn btn-link btn-sm text-warning p-1"
title="Restart"
disabled={!!actionId}
onClick={() => handleRestart(s.id)}
>
{actionId === s.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <RotateCw className="w-4 h-4" />}
{actionId === s.id ? (
<ActionSpinner show />
) : (
<i className="ti ti-rotate-clockwise" aria-hidden />
)}
</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"
type="button"
className="btn btn-link btn-sm text-danger p-1"
title="Stop"
disabled={!!actionId}
onClick={() => handleStop(s.id)}
>
<Square className="w-4 h-4" />
<i className="ti ti-square" aria-hidden />
</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"
type="button"
className="btn btn-link btn-sm text-success p-1"
title="Start"
disabled={!!actionId}
onClick={() => handleStart(s.id)}
>
{actionId === s.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{actionId === s.id ? (
<ActionSpinner show />
) : (
<i className="ti ti-player-play" aria-hidden />
)}
</button>
)}
</span>
@@ -138,8 +150,8 @@ export function ServicesPage() {
</tr>
))}
</tbody>
</table>
</AdminTable>
</div>
</div>
</>
)
}

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import {
apiRequest,
createSite,
@@ -16,7 +17,7 @@ import {
siteGitClone,
siteGitPull,
} from '../api/client'
import { Plus, Trash2, Download, Archive, RotateCcw, Pencil, Play, Square, ArrowRightLeft, GitBranch } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface Site {
id: number
@@ -40,7 +41,13 @@ export function SitePage() {
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 [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)
@@ -137,13 +144,15 @@ export function SitePage() {
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),
}))
.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))
}
@@ -197,7 +206,10 @@ export function SitePage() {
setRedirectAdding(true)
addSiteRedirect(redirectSiteId, redirectSource.trim(), redirectTarget.trim(), redirectCode)
.then(() => listSiteRedirects(redirectSiteId).then(setRedirects))
.then(() => { setRedirectSource(''); setRedirectTarget('') })
.then(() => {
setRedirectSource('')
setRedirectTarget('')
})
.catch((err) => setError(err.message))
.finally(() => setRedirectAdding(false))
}
@@ -242,374 +254,376 @@ export function SitePage() {
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>
if (loading) {
return (
<>
<PageHeader title="Website" />
<div className="text-center py-5 text-muted">Loading</div>
</>
)
}
if (error && !sites.length) {
return (
<>
<PageHeader title="Website" />
<AdminAlert variant="danger">{error}</AdminAlert>
</>
)
}
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>
<>
<PageHeader
title="Website"
actions={
<AdminButton onClick={() => setShowCreate(true)}>
<i className="ti ti-plus me-1" />
Add Site
</AdminButton>
}
/>
{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>
)}
{error ? <AdminAlert variant="warning">{error}</AdminAlert> : null}
<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 ? (
<Modal show={showCreate} onHide={() => setShowCreate(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Create Site</Modal.Title>
</Modal.Header>
<form onSubmit={handleCreate}>
<Modal.Body>
{creatingError ? <AdminAlert variant="danger">{creatingError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">Site Name</label>
<input name="name" type="text" placeholder="example.com" className="form-control" required />
</div>
<div className="mb-3">
<label className="form-label">Domain(s) (comma or space separated)</label>
<input
name="domains"
type="text"
placeholder="example.com www.example.com"
className="form-control"
required
/>
</div>
<div className="mb-3">
<label className="form-label">Path (optional)</label>
<input name="path" type="text" placeholder="/www/wwwroot/example.com" className="form-control" />
</div>
<div className="mb-3">
<label className="form-label">PHP Version</label>
<select name="php_version" className="form-select">
<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="form-check mb-3">
<input name="force_https" type="checkbox" id="create_force_https" className="form-check-input" />
<label htmlFor="create_force_https" className="form-check-label">
Force HTTPS (redirect HTTP to HTTPS)
</label>
</div>
<div className="mb-0">
<label className="form-label">Note (optional)</label>
<input name="ps" type="text" placeholder="My website" className="form-control" />
</div>
</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn-light" onClick={() => setShowCreate(false)}>
Cancel
</button>
<button type="submit" disabled={creating} className="btn btn-primary">
{creating ? 'Creating…' : 'Create'}
</button>
</Modal.Footer>
</form>
</Modal>
<div className="card shadow-sm border-0">
<div className="card-body p-0">
<AdminTable>
<thead className="table-light">
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
No sites yet. Click "Add Site" to create one.
</td>
<th>Name</th>
<th>Path</th>
<th>Domains</th>
<th>Type</th>
<th className="text-end">Actions</th>
</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">
</thead>
<tbody>
{sites.length === 0 ? (
<tr>
<td colSpan={5} className="p-0">
<EmptyState title="No sites yet" description='Click "Add Site" to create one.' />
</td>
</tr>
) : (
sites.map((s) => (
<tr key={s.id}>
<td className="align-middle">{s.name}</td>
<td className="align-middle text-muted">{s.path}</td>
<td className="align-middle">{s.domain_count}</td>
<td className="align-middle">{s.project_type}</td>
<td className="align-middle text-end text-nowrap">
{s.status === 1 ? (
<button
type="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"
className="btn btn-sm btn-outline-warning me-1"
title="Stop"
>
<Square className="w-4 h-4" />
<i className="ti ti-player-stop" />
</button>
) : (
<button
type="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"
className="btn btn-sm btn-outline-success me-1"
title="Start"
>
<Play className="w-4 h-4" />
<i className="ti ti-player-play" />
</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"
type="button"
onClick={() => {
setGitSiteId(s.id)
setGitAction('clone')
setGitUrl('')
setGitBranch('main')
}}
className="btn btn-sm btn-outline-success me-1"
title="Git Deploy"
>
<GitBranch className="w-4 h-4" />
<i className="ti ti-git-branch" />
</button>
<button
type="button"
onClick={() => openRedirectModal(s.id)}
className="p-2 text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 rounded"
className="btn btn-sm btn-outline-secondary me-1"
title="Redirects"
>
<ArrowRightLeft className="w-4 h-4" />
<i className="ti ti-arrows-right-left" />
</button>
<button
type="button"
onClick={() => openEditModal(s.id)}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
className="btn btn-sm btn-outline-primary me-1"
title="Edit"
>
<Pencil className="w-4 h-4" />
<i className="ti ti-pencil" />
</button>
<button
type="button"
onClick={() => openBackupModal(s.id)}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
className="btn btn-sm btn-outline-primary me-1"
title="Backup"
>
<Archive className="w-4 h-4" />
<i className="ti ti-archive" />
</button>
<button
type="button"
onClick={() => handleDelete(s.id, s.name)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
className="btn btn-sm btn-outline-danger"
title="Delete"
>
<Trash2 className="w-4 h-4" />
<i className="ti ti-trash" />
</button>
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</td>
</tr>
))
)}
</tbody>
</AdminTable>
</div>
</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>
)}
<Modal show={!!gitSiteId && !!gitAction} onHide={() => { setGitSiteId(null); setGitAction(null) }} centered>
<Modal.Header closeButton>
<Modal.Title>Git Deploy</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="btn-group mb-3" role="group">
<button
type="button"
className={`btn btn-sm ${gitAction === 'clone' ? 'btn-success' : 'btn-outline-secondary'}`}
onClick={() => setGitAction('clone')}
>
Clone
</button>
<button
type="button"
className={`btn btn-sm ${gitAction === 'pull' ? 'btn-success' : 'btn-outline-secondary'}`}
onClick={() => setGitAction('pull')}
>
Pull
</button>
</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">
{gitAction === 'clone' ? (
<form onSubmit={handleGitClone}>
<div className="mb-3">
<label className="form-label">Repository URL</label>
<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"
value={gitUrl}
onChange={(e) => setGitUrl(e.target.value)}
placeholder="https://github.com/user/repo.git"
className="form-control"
required
/>
<span className="self-center text-gray-500"></span>
</div>
<div className="mb-3">
<label className="form-label">Branch</label>
<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"
value={gitBranch}
onChange={(e) => setGitBranch(e.target.value)}
placeholder="main"
className="form-control"
/>
<select
value={redirectCode}
onChange={(e) => setRedirectCode(Number(e.target.value))}
className="px-3 py-2 border rounded-lg bg-white dark:bg-gray-700"
</div>
<p className="small text-muted">Site path must be empty for clone.</p>
<div className="d-flex justify-content-end gap-2">
<button
type="button"
className="btn btn-light"
onClick={() => {
setGitSiteId(null)
setGitAction(null)
}}
>
<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
Cancel
</button>
<button type="submit" disabled={gitLoading} className="btn btn-success">
{gitLoading ? 'Cloning…' : 'Clone'}
</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>
<p className="text-muted small">Pull latest changes from the remote repository.</p>
<div className="d-flex justify-content-end gap-2">
<button
type="button"
className="btn btn-light"
onClick={() => {
setGitSiteId(null)
setGitAction(null)
}}
>
Cancel
</button>
<button type="button" onClick={handleGitPull} disabled={gitLoading} className="btn btn-success">
{gitLoading ? 'Pulling…' : 'Pull'}
</button>
</div>
</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
)}
</Modal.Body>
</Modal>
<Modal show={redirectSiteId != null} onHide={() => setRedirectSiteId(null)} size="lg" centered scrollable>
<Modal.Header closeButton>
<Modal.Title>Redirects</Modal.Title>
</Modal.Header>
<Modal.Body>
<form onSubmit={handleAddRedirect} className="row g-2 align-items-end mb-3">
<div className="col-md-3">
<label className="form-label small">Source</label>
<input
value={redirectSource}
onChange={(e) => setRedirectSource(e.target.value)}
placeholder="/old-path"
className="form-control form-control-sm"
/>
</div>
<div className="col-md-3">
<label className="form-label small">Target</label>
<input
value={redirectTarget}
onChange={(e) => setRedirectTarget(e.target.value)}
placeholder="/new-path"
className="form-control form-control-sm"
/>
</div>
<div className="col-md-2">
<label className="form-label small">Code</label>
<select
value={redirectCode}
onChange={(e) => setRedirectCode(Number(e.target.value))}
className="form-select form-select-sm"
>
<option value={301}>301</option>
<option value={302}>302</option>
</select>
</div>
<div className="col-md-2">
<button type="submit" disabled={redirectAdding} className="btn btn-primary btn-sm w-100">
Add
</button>
</div>
</div>
</div>
)}
</form>
{redirects.length === 0 ? (
<p className="text-muted small mb-0">No redirects</p>
) : (
<ul className="list-group list-group-flush">
{redirects.map((r) => (
<li key={r.id} className="list-group-item d-flex align-items-center gap-2 small">
<code className="text-truncate">{r.source}</code>
<span className="text-muted"></span>
<code className="text-truncate flex-grow-1">{r.target}</code>
<span className="badge bg-secondary">{r.code}</span>
<button type="button" className="btn btn-sm btn-link text-danger p-0" onClick={() => handleDeleteRedirect(r.id)}>
<i className="ti ti-trash" />
</button>
</li>
))}
</ul>
)}
</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn-light" onClick={() => setRedirectSiteId(null)}>
Close
</button>
</Modal.Footer>
</Modal>
{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>
<Modal show={editSiteId != null && editForm != null} onHide={() => { setEditSiteId(null); setEditForm(null) }} centered>
<Modal.Header closeButton>
<Modal.Title>Edit Site</Modal.Title>
</Modal.Header>
{editForm ? (
<form onSubmit={handleEdit}>
<Modal.Body>
{editError ? <AdminAlert variant="danger">{editError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">Domain(s)</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"
className="form-control"
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>
<div className="mb-3">
<label className="form-label">Path (optional)</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"
className="form-control"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">PHP Version</label>
<div className="mb-3">
<label className="form-label">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"
className="form-select"
>
<option value="74">7.4</option>
<option value="80">8.0</option>
@@ -617,108 +631,87 @@ export function SitePage() {
<option value="82">8.2</option>
</select>
</div>
<div className="flex items-center gap-2">
<div className="form-check mb-3">
<input
type="checkbox"
id="edit_force_https"
className="form-check-input"
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">
<label htmlFor="edit_force_https" className="form-check-label">
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>
<div className="mb-0">
<label className="form-label">Note (optional)</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"
className="form-control"
/>
</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>
)}
</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn-light" onClick={() => { setEditSiteId(null); setEditForm(null) }}>
Cancel
</button>
<button type="submit" disabled={editLoading} className="btn btn-primary">
{editLoading ? 'Saving…' : 'Save'}
</button>
</Modal.Footer>
</form>
) : null}
</Modal>
{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>
<Modal show={backupSiteId != null} onHide={() => setBackupSiteId(null)} size="lg" centered>
<Modal.Header closeButton>
<Modal.Title>Site Backup</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="mb-3">
<AdminButton onClick={handleCreateBackup} disabled={backupLoading}>
<i className="ti ti-archive me-1" />
{backupLoading ? 'Creating…' : 'Create Backup'}
</AdminButton>
</div>
</div>
)}
</div>
<h6 className="text-muted small">Existing backups</h6>
{backups.length === 0 ? (
<p className="text-muted small">No backups yet</p>
) : (
<ul className="list-group list-group-flush">
{backups.map((b) => (
<li key={b.filename} className="list-group-item d-flex align-items-center justify-content-between gap-2">
<code className="small text-truncate flex-grow-1">{b.filename}</code>
<span className="text-muted small">{formatSize(b.size)}</span>
<button
type="button"
className="btn btn-sm btn-link"
onClick={() => handleDownloadBackup(b.filename)}
title="Download"
>
<i className="ti ti-download" />
</button>
<button
type="button"
className="btn btn-sm btn-link text-warning"
onClick={() => handleRestore(b.filename)}
disabled={backupLoading}
>
<i className="ti ti-restore" />
</button>
</li>
))}
</ul>
)}
</Modal.Body>
<Modal.Footer>
<button type="button" className="btn btn-light" onClick={() => setBackupSiteId(null)}>
Close
</button>
</Modal.Footer>
</Modal>
</>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { apiRequest } from '../api/client'
import { Check, X, Loader2, Package } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert } from '../components/admin'
interface Software {
id: string
@@ -53,87 +53,97 @@ export function SoftPage() {
.finally(() => setActionId(null))
}
if (loading) return <div className="text-gray-500">Loading...</div>
if (loading) {
return (
<>
<PageHeader title="App Store" />
<div className="d-flex justify-content-center py-5">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading</span>
</div>
</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>
<>
<PageHeader
title="App Store"
actions={
<AdminButton variant="secondary" onClick={load}>
<i className="ti ti-refresh me-1" />
Refresh
</AdminButton>
}
/>
{error ? <AdminAlert variant="danger">{error}</AdminAlert> : null}
<div className="alert alert-warning" role="note">
Installs use your server package manager ({detectedPm || 'unknown'}). Panel must run as root (or
equivalent). Supported: apt, dnf/yum/microdnf, apk.
</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">
Installs use your server package manager ({detectedPm || '…loading…'}). Panel must run as root
(or equivalent). Supported: apt, dnf/yum/microdnf, apk.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="row g-3">
{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 key={s.id} className="col-md-6 col-xl-4 d-flex">
<div className="card flex-fill shadow-sm">
<div className="card-body d-flex flex-column">
<div className="d-flex align-items-start justify-content-between gap-2 mb-2">
<div className="d-flex align-items-start gap-2">
<span className="avatar avatar-md bg-primary-transparent text-primary rounded flex-shrink-0">
<i className="ti ti-package fs-5" aria-hidden />
</span>
<div>
<h5 className="card-title mb-1">{s.name}</h5>
<p className="text-muted small mb-0">{s.desc}</p>
</div>
</div>
{s.installed ? (
<span className="text-success small text-nowrap">
<i className="ti ti-circle-check me-1" />
{s.version || 'Installed'}
</span>
) : (
<span className="text-muted small text-nowrap">
<i className="ti ti-x me-1" />
Not installed
</span>
)}
</div>
<div className="mt-auto pt-3">
{s.installed ? (
<button
type="button"
onClick={() => handleUninstall(s.id, s.name)}
disabled={actionId === s.id}
className="btn btn-outline-danger btn-sm w-100 d-inline-flex align-items-center justify-content-center gap-2"
>
{actionId === s.id ? (
<span className="spinner-border spinner-border-sm" role="status" />
) : null}
Uninstall
</button>
) : (
<button
type="button"
onClick={() => handleInstall(s.id)}
disabled={actionId === s.id}
className="btn btn-primary btn-sm w-100 d-inline-flex align-items-center justify-content-center gap-2"
>
{actionId === s.id ? (
<span className="spinner-border spinner-border-sm" role="status" />
) : null}
Install
</button>
)}
</div>
</div>
{s.installed ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 text-sm flex-shrink-0">
<Check className="w-4 h-4" />
{s.version || 'Installed'}
</span>
) : (
<span className="flex items-center gap-1 text-gray-500 text-sm flex-shrink-0">
<X className="w-4 h-4" />
Not installed
</span>
)}
</div>
<div className="mt-auto pt-3">
{s.installed ? (
<button
onClick={() => handleUninstall(s.id, s.name)}
disabled={actionId === s.id}
className="w-full px-3 py-2 text-sm rounded-lg border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 flex items-center justify-center gap-2"
>
{actionId === s.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Uninstall'
)}
</button>
) : (
<button
onClick={() => handleInstall(s.id)}
disabled={actionId === s.id}
className="w-full px-3 py-2 text-sm rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 flex items-center justify-center gap-2"
>
{actionId === s.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Install'
)}
</button>
)}
</div>
</div>
))}
</div>
</div>
</>
)
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import 'xterm/css/xterm.css'
import { PageHeader } from '../components/admin'
export function TerminalPage() {
const containerRef = useRef<HTMLDivElement>(null)
@@ -72,13 +73,18 @@ export function TerminalPage() {
}, [])
return (
<div>
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">Terminal</h1>
<>
<PageHeader title="Terminal" />
<div
ref={containerRef}
className="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700"
style={{ height: 'calc(100vh - 200px)', minHeight: 400 }}
/>
</div>
className="card overflow-hidden"
style={{ minHeight: 0 }}
>
<div
ref={containerRef}
className="yakpanel-terminal-host"
style={{ height: 'calc(100vh - 12rem)', minHeight: 400 }}
/>
</div>
</>
)
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import { apiRequest, listUsers, createUser, deleteUser, toggleUserActive } from '../api/client'
import { Plus, Trash2, UserCheck, UserX } from 'lucide-react'
import { PageHeader, AdminButton, AdminAlert, AdminTable } from '../components/admin'
interface UserRecord {
id: number
@@ -74,121 +75,116 @@ export function UsersPage() {
.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>
if (loading && users.length === 0) {
return (
<>
<PageHeader title="Users" />
<p className="text-secondary">Loading</p>
</>
)
}
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>
<>
<PageHeader
title="Users"
actions={
<AdminButton variant="primary" onClick={() => setShowCreate(true)}>
<i className="ti ti-plus me-1" aria-hidden />
Add User
</AdminButton>
}
/>
<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">
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<div className="card">
<AdminTable>
<thead>
<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>
<th>Username</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
<tbody>
{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>
</>
)}
<td>{u.username}</td>
<td>{u.email || ''}</td>
<td>
<span className={u.is_active ? 'text-success' : 'text-secondary'}>
{u.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td>{u.is_superuser ? 'Admin' : 'User'}</td>
<td className="text-end">
{u.id !== currentUserId ? (
<span className="d-inline-flex gap-1 justify-content-end">
<button
type="button"
className={`btn btn-link btn-sm p-1 ${u.is_active ? 'text-warning' : 'text-success'}`}
title={u.is_active ? 'Deactivate' : 'Activate'}
onClick={() => handleToggleActive(u.id)}
>
<i className={u.is_active ? 'ti ti-user-x' : 'ti ti-user-check'} aria-hidden />
</button>
<button
type="button"
className="btn btn-link btn-sm text-danger p-1"
title="Delete"
onClick={() => handleDelete(u.id, u.username)}
>
<i className="ti ti-trash" aria-hidden />
</button>
</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</AdminTable>
</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>
<Modal show={showCreate} onHide={() => setShowCreate(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Add User</Modal.Title>
</Modal.Header>
<form onSubmit={handleCreate}>
<Modal.Body>
{createError ? <AdminAlert className="mb-3">{createError}</AdminAlert> : null}
<div className="mb-3">
<label className="form-label">Username</label>
<input
name="username"
type="text"
placeholder="newuser"
className="form-control"
required
minLength={2}
/>
</div>
<div className="mb-3">
<label className="form-label">Password</label>
<input name="password" type="password" placeholder="••••••••" className="form-control" required minLength={6} />
</div>
<div className="mb-0">
<label className="form-label">Email (optional)</label>
<input name="email" type="email" placeholder="user@example.com" className="form-control" />
</div>
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => setShowCreate(false)}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={creating}>
{creating ? 'Creating…' : 'Create'}
</AdminButton>
</Modal.Footer>
</form>
</Modal>
</>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
/* YakPanel — tweaks on top of html-admin style.css */
/* Primary / accent bridge: template uses [data-color] on <html>; optional fine-tuning */
:root {
--yakpanel-focus-ring: var(--bs-primary);
}
#root {
min-height: 100vh;
}
/* SPAyakPanel: React root fills viewport inside main-wrapper */
.main-wrapper {
min-height: 100vh;
}
/* Theme customizer: ensure panel stacks above mobile menu overlay */
.sidebar-themesettings.open {
z-index: 1050;
}
.sidebar-contact .toggle-theme:focus-visible {
outline: 2px solid var(--yakpanel-focus-ring, #0d6efd);
outline-offset: 2px;
}
/* Terminal / file manager flex children */
.page-content-scroll {
min-height: 0;
flex: 1;
overflow: auto;
}