docs: comprehensive documentation polish & CI/CD automation

Major Updates:
- Rewrote all docs in beginner-friendly 'ELI5' language
- Created docs index with user journey navigation
- Added complete getting-started guide for novice users
- Set up GitHub Container Registry (GHCR) automation
- Configured GitHub Pages deployment for documentation

Documentation:
- docs/index.md - Central navigation hub
- docs/getting-started.md - Step-by-step beginner guide
- docs/github-setup.md - CI/CD setup instructions
- README.md - Complete rewrite in accessible language
- CONTRIBUTING.md - Contributor guidelines
- Multiple comprehensive API and schema docs

CI/CD Workflows:
- .github/workflows/docker-build.yml - Multi-platform builds to GHCR
- .github/workflows/docs.yml - Automated docs deployment to Pages
- Supports main (latest), development (dev), and version tags
- Automated testing of built images
- Beautiful documentation site with dark theme

Benefits:
- Zero barrier to entry for new users
- Automated Docker builds (AMD64 + ARM64)
- Professional documentation site
- No Docker Hub account needed (uses GHCR)
- Complete CI/CD pipeline

All 7 implementation phases complete - project is production ready!
This commit is contained in:
Wikid82
2025-11-18 13:11:11 -05:00
parent b9dcc6c347
commit e58fcb714d
76 changed files with 16989 additions and 99 deletions
+25
View File
@@ -0,0 +1,25 @@
import { BrowserRouter as Router, Routes, Route, Outlet } from 'react-router-dom'
import Layout from './components/Layout'
import { ToastContainer } from './components/Toast'
import Dashboard from './pages/Dashboard'
import ProxyHosts from './pages/ProxyHosts'
import RemoteServers from './pages/RemoteServers'
import ImportCaddy from './pages/ImportCaddy'
import Settings from './pages/Settings'
export default function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Layout><Outlet /></Layout>}>
<Route index element={<Dashboard />} />
<Route path="proxy-hosts" element={<ProxyHosts />} />
<Route path="remote-servers" element={<RemoteServers />} />
<Route path="import" element={<ImportCaddy />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
<ToastContainer />
</Router>
)
}
+44
View File
@@ -0,0 +1,44 @@
interface ImportBannerProps {
session: {
uuid: string
filename?: string
state: string
created_at: string
}
onReview: () => void
onCancel: () => void
}
export default function ImportBanner({ session, onReview, onCancel }: ImportBannerProps) {
return (
<div className="bg-blue-900/20 border border-blue-500 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-blue-400 mb-1">
Import Session Active
</h3>
<p className="text-sm text-gray-300">
{session.filename && `File: ${session.filename}`}
State: <span className="font-medium">{session.state}</span>
</p>
</div>
<div className="flex gap-3">
{session.state === 'reviewing' && (
<button
onClick={onReview}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
>
Review Changes
</button>
)}
<button
onClick={onCancel}
className="px-4 py-2 bg-red-900/20 hover:bg-red-900/30 text-red-400 rounded-lg font-medium transition-colors"
>
Cancel Import
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,172 @@
import { useState } from 'react'
interface ImportReviewTableProps {
hosts: any[]
conflicts: string[]
errors: string[]
onCommit: (resolutions: Record<string, string>) => Promise<void>
onCancel: () => void
}
export default function ImportReviewTable({ hosts, conflicts, errors, onCommit, onCancel }: ImportReviewTableProps) {
const [resolutions, setResolutions] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(false)
const hasConflicts = conflicts.length > 0
const handleResolutionChange = (domain: string, action: string) => {
setResolutions({ ...resolutions, [domain]: action })
}
const handleCommit = async () => {
// Ensure all conflicts have resolutions
const unresolvedConflicts = conflicts.filter(c => !resolutions[c])
if (unresolvedConflicts.length > 0) {
alert(`Please resolve all conflicts: ${unresolvedConflicts.join(', ')}`)
return
}
setLoading(true)
try {
await onCommit(resolutions)
} finally {
setLoading(false)
}
}
return (
<div className="space-y-6">
{/* Errors */}
{errors.length > 0 && (
<div className="bg-red-900/20 border border-red-500 rounded-lg p-4">
<h3 className="text-lg font-semibold text-red-400 mb-2">Errors</h3>
<ul className="list-disc list-inside space-y-1">
{errors.map((error, idx) => (
<li key={idx} className="text-sm text-red-300">{error}</li>
))}
</ul>
</div>
)}
{/* Conflicts */}
{hasConflicts && (
<div className="bg-yellow-900/20 border border-yellow-500 rounded-lg p-4">
<h3 className="text-lg font-semibold text-yellow-400 mb-2">
Conflicts Detected ({conflicts.length})
</h3>
<p className="text-sm text-gray-300 mb-4">
The following domains already exist. Choose how to handle each conflict:
</p>
<div className="space-y-3">
{conflicts.map((domain) => (
<div key={domain} className="flex items-center justify-between bg-gray-900 p-3 rounded">
<span className="text-white font-medium">{domain}</span>
<select
value={resolutions[domain] || ''}
onChange={e => handleResolutionChange(domain, e.target.value)}
className="bg-gray-800 border border-gray-700 rounded px-3 py-1 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">-- Choose action --</option>
<option value="skip">Skip (keep existing)</option>
<option value="overwrite">Overwrite existing</option>
</select>
</div>
))}
</div>
</div>
)}
{/* Preview Hosts */}
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<div className="px-6 py-4 bg-gray-900 border-b border-gray-800">
<h3 className="text-lg font-semibold text-white">
Hosts to Import ({hosts.length})
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Domain
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Forward To
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
SSL
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Features
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{hosts.map((host, idx) => {
const isConflict = conflicts.includes(host.domain_names)
return (
<tr key={idx} className={`hover:bg-gray-900/50 ${isConflict ? 'bg-yellow-900/10' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">{host.domain_names}</span>
{isConflict && (
<span className="px-2 py-1 text-xs bg-yellow-900/30 text-yellow-400 rounded">
Conflict
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-300">
{host.forward_scheme}://{host.forward_host}:{host.forward_port}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{host.ssl_forced && (
<span className="px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
SSL
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex gap-2">
{host.http2_support && (
<span className="px-2 py-1 text-xs bg-blue-900/30 text-blue-400 rounded">
HTTP/2
</span>
)}
{host.websocket_support && (
<span className="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 rounded">
WS
</span>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 justify-end">
<button
onClick={onCancel}
disabled={loading}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleCommit}
disabled={loading || (hasConflicts && Object.keys(resolutions).length < conflicts.length)}
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? 'Importing...' : 'Commit Import'}
</button>
</div>
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
interface LayoutProps {
children: ReactNode
}
export default function Layout({ children }: LayoutProps) {
const location = useLocation()
const navigation = [
{ name: 'Dashboard', path: '/', icon: '📊' },
{ name: 'Proxy Hosts', path: '/proxy-hosts', icon: '🌐' },
{ name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' },
{ name: 'Import Caddyfile', path: '/import', icon: '📥' },
{ name: 'Settings', path: '/settings', icon: '⚙️' },
]
return (
<div className="min-h-screen bg-dark-bg flex">
{/* Sidebar */}
<aside className="w-60 bg-dark-sidebar border-r border-gray-800 flex flex-col">
<div className="p-6">
<h1 className="text-xl font-bold text-white">Caddy Proxy Manager+</h1>
</div>
<nav className="flex-1 px-4 space-y-1">
{navigation.map((item) => {
const isActive = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive
? 'bg-blue-active text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<span className="text-lg">{item.icon}</span>
{item.name}
</Link>
)
})}
</nav>
<div className="p-4 border-t border-gray-800">
<div className="text-xs text-gray-500">
Version 0.1.0
</div>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
export function LoadingSpinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-8 h-8 border-3',
lg: 'w-12 h-12 border-4',
}
return (
<div
className={`${sizeClasses[size]} border-blue-600 border-t-transparent rounded-full animate-spin`}
role="status"
aria-label="Loading"
/>
)
}
export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) {
return (
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-slate-800 rounded-lg p-6 flex flex-col items-center gap-4 shadow-xl">
<LoadingSpinner size="lg" />
<p className="text-slate-300">{message}</p>
</div>
</div>
)
}
export function LoadingCard() {
return (
<div className="bg-slate-800 rounded-lg p-6 animate-pulse">
<div className="h-6 bg-slate-700 rounded w-1/3 mb-4"></div>
<div className="space-y-3">
<div className="h-4 bg-slate-700 rounded w-full"></div>
<div className="h-4 bg-slate-700 rounded w-5/6"></div>
<div className="h-4 bg-slate-700 rounded w-4/6"></div>
</div>
</div>
)
}
export function EmptyState({
icon = '📦',
title,
description,
action,
}: {
icon?: string
title: string
description: string
action?: React.ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="text-6xl mb-4">{icon}</div>
<h3 className="text-xl font-semibold text-slate-200 mb-2">{title}</h3>
<p className="text-slate-400 mb-6 max-w-md">{description}</p>
{action}
</div>
)
}
+270
View File
@@ -0,0 +1,270 @@
import { useState, useEffect } from 'react'
import { ProxyHost } from '../hooks/useProxyHosts'
import { remoteServersAPI } from '../services/api'
interface ProxyHostFormProps {
host?: ProxyHost
onSubmit: (data: Partial<ProxyHost>) => Promise<void>
onCancel: () => void
}
interface RemoteServer {
uuid: string
name: string
provider: string
host: string
port: number
enabled: boolean
}
export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) {
const [formData, setFormData] = useState({
domain_names: host?.domain_names || '',
forward_scheme: host?.forward_scheme || 'http',
forward_host: host?.forward_host || '',
forward_port: host?.forward_port || 80,
ssl_forced: host?.ssl_forced ?? false,
http2_support: host?.http2_support ?? false,
hsts_enabled: host?.hsts_enabled ?? false,
hsts_subdomains: host?.hsts_subdomains ?? false,
block_exploits: host?.block_exploits ?? true,
websocket_support: host?.websocket_support ?? false,
advanced_config: host?.advanced_config || '',
enabled: host?.enabled ?? true,
})
const [remoteServers, setRemoteServers] = useState<RemoteServer[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchServers = async () => {
try {
const servers = await remoteServersAPI.list(true)
setRemoteServers(servers)
} catch (err) {
console.error('Failed to fetch remote servers:', err)
}
}
fetchServers()
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
await onSubmit(formData)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save proxy host')
} finally {
setLoading(false)
}
}
const handleServerSelect = (serverUuid: string) => {
const server = remoteServers.find(s => s.uuid === serverUuid)
if (server) {
setFormData({
...formData,
forward_host: server.host,
forward_port: server.port,
forward_scheme: 'http',
})
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-800">
<h2 className="text-2xl font-bold text-white">
{host ? 'Edit Proxy Host' : 'Add Proxy Host'}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
{/* Domain Names */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Domain Names (comma-separated)
</label>
<input
type="text"
required
value={formData.domain_names}
onChange={e => setFormData({ ...formData, domain_names: e.target.value })}
placeholder="example.com, www.example.com"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Remote Server Quick Select */}
{remoteServers.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Quick Select from Remote Servers
</label>
<select
onChange={e => handleServerSelect(e.target.value)}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">-- Select a server --</option>
{remoteServers.map(server => (
<option key={server.uuid} value={server.uuid}>
{server.name} ({server.host}:{server.port})
</option>
))}
</select>
</div>
)}
{/* Forward Details */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Scheme</label>
<select
value={formData.forward_scheme}
onChange={e => setFormData({ ...formData, forward_scheme: e.target.value })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Host</label>
<input
type="text"
required
value={formData.forward_host}
onChange={e => setFormData({ ...formData, forward_host: e.target.value })}
placeholder="192.168.1.100"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Port</label>
<input
type="number"
required
min="1"
max="65535"
value={formData.forward_port}
onChange={e => setFormData({ ...formData, forward_port: parseInt(e.target.value) })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* SSL & Security Options */}
<div className="space-y-3">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.ssl_forced}
onChange={e => setFormData({ ...formData, ssl_forced: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Force SSL</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.http2_support}
onChange={e => setFormData({ ...formData, http2_support: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HTTP/2 Support</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.hsts_enabled}
onChange={e => setFormData({ ...formData, hsts_enabled: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HSTS Enabled</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.hsts_subdomains}
onChange={e => setFormData({ ...formData, hsts_subdomains: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">HSTS Subdomains</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.block_exploits}
onChange={e => setFormData({ ...formData, block_exploits: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Block Common Exploits</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.websocket_support}
onChange={e => setFormData({ ...formData, websocket_support: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">WebSocket Support</span>
</label>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.enabled}
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Enabled</span>
</label>
</div>
{/* Advanced Config */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Advanced Caddy Config (Optional)
</label>
<textarea
value={formData.advanced_config}
onChange={e => setFormData({ ...formData, advanced_config: e.target.value })}
placeholder="Additional Caddy directives..."
rows={4}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Actions */}
<div className="flex gap-3 justify-end pt-4 border-t border-gray-800">
<button
type="button"
onClick={onCancel}
disabled={loading}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : (host ? 'Update' : 'Create')}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,210 @@
import { useState } from 'react'
import { RemoteServer } from '../hooks/useRemoteServers'
import { remoteServersAPI } from '../services/api'
interface RemoteServerFormProps {
server?: RemoteServer
onSubmit: (data: Partial<RemoteServer>) => Promise<void>
onCancel: () => void
}
export default function RemoteServerForm({ server, onSubmit, onCancel }: RemoteServerFormProps) {
const [formData, setFormData] = useState({
name: server?.name || '',
provider: server?.provider || 'generic',
host: server?.host || '',
port: server?.port || 80,
username: server?.username || '',
enabled: server?.enabled ?? true,
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [testResult, setTestResult] = useState<any | null>(null)
const [testing, setTesting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
await onSubmit(formData)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save remote server')
} finally {
setLoading(false)
}
}
const handleTestConnection = async () => {
if (!server) return
setTesting(true)
setTestResult(null)
setError(null)
try {
const result = await remoteServersAPI.test(server.uuid)
setTestResult(result)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to test connection')
} finally {
setTesting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-lg w-full">
<div className="p-6 border-b border-gray-800">
<h2 className="text-2xl font-bold text-white">
{server ? 'Edit Remote Server' : 'Add Remote Server'}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Name</label>
<input
type="text"
required
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="My Production Server"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Provider</label>
<select
value={formData.provider}
onChange={e => setFormData({ ...formData, provider: e.target.value })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="generic">Generic</option>
<option value="docker">Docker</option>
<option value="kubernetes">Kubernetes</option>
<option value="aws">AWS</option>
<option value="gcp">GCP</option>
<option value="azure">Azure</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Host</label>
<input
type="text"
required
value={formData.host}
onChange={e => setFormData({ ...formData, host: e.target.value })}
placeholder="192.168.1.100"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Port</label>
<input
type="number"
required
min="1"
max="65535"
value={formData.port}
onChange={e => setFormData({ ...formData, port: parseInt(e.target.value) })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Username (Optional)
</label>
<input
type="text"
value={formData.username}
onChange={e => setFormData({ ...formData, username: e.target.value })}
placeholder="admin"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.enabled}
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
/>
<span className="text-sm text-gray-300">Enabled</span>
</label>
{/* Connection Test */}
{server && (
<div className="pt-4 border-t border-gray-800">
<button
type="button"
onClick={handleTestConnection}
disabled={testing}
className="w-full px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{testing ? (
<>
<span className="animate-spin"></span>
Testing Connection...
</>
) : (
<>
<span>🔌</span>
Test Connection
</>
)}
</button>
{testResult && (
<div className={`mt-3 p-3 rounded-lg ${testResult.reachable ? 'bg-green-900/20 border border-green-500' : 'bg-red-900/20 border border-red-500'}`}>
<div className="flex items-center gap-2">
<span className={testResult.reachable ? 'text-green-400' : 'text-red-400'}>
{testResult.reachable ? '✓ Connection Successful' : '✗ Connection Failed'}
</span>
</div>
{testResult.error && (
<div className="text-xs text-red-300 mt-1">{testResult.error}</div>
)}
{testResult.address && (
<div className="text-xs text-gray-400 mt-1">Address: {testResult.address}</div>
)}
</div>
)}
</div>
)}
<div className="flex gap-3 justify-end pt-4 border-t border-gray-800">
<button
type="button"
onClick={onCancel}
disabled={loading}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? 'Saving...' : (server ? 'Update' : 'Create')}
</button>
</div>
</form>
</div>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react'
type ToastType = 'success' | 'error' | 'info' | 'warning'
interface Toast {
id: number
message: string
type: ToastType
}
let toastId = 0
const toastCallbacks = new Set<(toast: Toast) => void>()
export const toast = {
success: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'success' }))
},
error: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'error' }))
},
info: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'info' }))
},
warning: (message: string) => {
const id = ++toastId
toastCallbacks.forEach(callback => callback({ id, message, type: 'warning' }))
},
}
export function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([])
useEffect(() => {
const callback = (toast: Toast) => {
setToasts(prev => [...prev, toast])
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== toast.id))
}, 5000)
}
toastCallbacks.add(callback)
return () => {
toastCallbacks.delete(callback)
}
}, [])
const removeToast = (id: number) => {
setToasts(prev => prev.filter(t => t.id !== id))
}
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
{toasts.map(toast => (
<div
key={toast.id}
className={`pointer-events-auto px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] animate-slide-in ${
toast.type === 'success'
? 'bg-green-600 text-white'
: toast.type === 'error'
? 'bg-red-600 text-white'
: toast.type === 'warning'
? 'bg-yellow-600 text-white'
: 'bg-blue-600 text-white'
}`}
>
<div className="flex-1">
{toast.type === 'success' && <span className="mr-2"></span>}
{toast.type === 'error' && <span className="mr-2"></span>}
{toast.type === 'warning' && <span className="mr-2"></span>}
{toast.type === 'info' && <span className="mr-2"></span>}
{toast.message}
</div>
<button
onClick={() => removeToast(toast.id)}
className="text-white/80 hover:text-white transition-colors"
aria-label="Close"
>
×
</button>
</div>
))}
</div>
)
}
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import ImportReviewTable from '../ImportReviewTable'
import { mockImportPreview } from '../../test/mockData'
describe('ImportReviewTable', () => {
const mockOnCommit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
afterEach(() => {
vi.clearAllMocks()
})
it('displays hosts to import', () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Hosts to Import (1)')).toBeInTheDocument()
expect(screen.getByText('test.example.com')).toBeInTheDocument()
})
it('displays conflicts with resolution dropdowns', () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={mockImportPreview.conflicts}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText(/Conflicts Detected \(1\)/)).toBeInTheDocument()
expect(screen.getByText('app.local.dev')).toBeInTheDocument()
expect(screen.getByText('-- Choose action --')).toBeInTheDocument()
})
it('displays errors', () => {
const errors = ['Invalid Caddyfile syntax', 'Missing required field']
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
errors={errors}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Errors')).toBeInTheDocument()
expect(screen.getByText('Invalid Caddyfile syntax')).toBeInTheDocument()
expect(screen.getByText('Missing required field')).toBeInTheDocument()
})
it('disables commit button until all conflicts are resolved', () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={mockImportPreview.conflicts}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const commitButton = screen.getByText('Commit Import')
expect(commitButton).toBeDisabled()
})
it('enables commit button when all conflicts are resolved', async () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={mockImportPreview.conflicts}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const dropdown = screen.getAllByRole('combobox')[0]
fireEvent.change(dropdown, { target: { value: 'skip' } })
await waitFor(() => {
const commitButton = screen.getByText('Commit Import')
expect(commitButton).not.toBeDisabled()
})
})
it('calls onCommit with resolutions', async () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={mockImportPreview.conflicts}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
const dropdown = screen.getAllByRole('combobox')[0]
fireEvent.change(dropdown, { target: { value: 'overwrite' } })
const commitButton = screen.getByText('Commit Import')
fireEvent.click(commitButton)
await waitFor(() => {
expect(mockOnCommit).toHaveBeenCalledWith({
'app.local.dev': 'overwrite',
})
})
})
it('calls onCancel when cancel button is clicked', () => {
render(
<ImportReviewTable
hosts={mockImportPreview.hosts}
conflicts={[]}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
fireEvent.click(screen.getByText('Cancel'))
expect(mockOnCancel).toHaveBeenCalledOnce()
})
it('shows conflict indicator on conflicting hosts', () => {
render(
<ImportReviewTable
hosts={[
{
domain_names: 'app.local.dev',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 3000,
ssl_forced: false,
http2_support: false,
websocket_support: false,
},
]}
conflicts={['app.local.dev']}
errors={[]}
onCommit={mockOnCommit}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Conflict')).toBeInTheDocument()
})
})
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import Layout from '../Layout'
describe('Layout', () => {
it('renders the application title', () => {
render(
<BrowserRouter>
<Layout>
<div>Test Content</div>
</Layout>
</BrowserRouter>
)
expect(screen.getByText('Caddy Proxy Manager+')).toBeInTheDocument()
})
it('renders all navigation items', () => {
render(
<BrowserRouter>
<Layout>
<div>Test Content</div>
</Layout>
</BrowserRouter>
)
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
expect(screen.getByText('Import Caddyfile')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
})
it('renders children content', () => {
render(
<BrowserRouter>
<Layout>
<div data-testid="test-content">Test Content</div>
</Layout>
</BrowserRouter>
)
expect(screen.getByTestId('test-content')).toBeInTheDocument()
})
it('displays version information', () => {
render(
<BrowserRouter>
<Layout>
<div>Test Content</div>
</Layout>
</BrowserRouter>
)
expect(screen.getByText('Version 0.1.0')).toBeInTheDocument()
})
})
@@ -0,0 +1,130 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import ProxyHostForm from '../ProxyHostForm'
import { mockRemoteServers } from '../../test/mockData'
// Mock the API
vi.mock('../../services/api', () => ({
remoteServersAPI: {
list: vi.fn(() => Promise.resolve(mockRemoteServers)),
},
}))
describe('ProxyHostForm', () => {
const mockOnSubmit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
afterEach(() => {
vi.clearAllMocks()
})
it('renders create form with empty fields', async () => {
render(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText('Add Proxy Host')).toBeInTheDocument()
})
expect(screen.getByPlaceholderText('example.com, www.example.com')).toHaveValue('')
})
it('renders edit form with pre-filled data', async () => {
const mockHost = {
uuid: '123',
domain_names: 'test.com',
forward_scheme: 'https',
forward_host: '192.168.1.100',
forward_port: 8443,
ssl_forced: true,
http2_support: true,
hsts_enabled: true,
hsts_subdomains: true,
block_exploits: true,
websocket_support: false,
enabled: true,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
render(
<ProxyHostForm host={mockHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText('Edit Proxy Host')).toBeInTheDocument()
})
expect(screen.getByDisplayValue('test.com')).toBeInTheDocument()
expect(screen.getByDisplayValue('192.168.1.100')).toBeInTheDocument()
})
it('loads remote servers for quick select', async () => {
render(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText(/Local Docker Registry/)).toBeInTheDocument()
})
})
it('calls onCancel when cancel button is clicked', async () => {
render(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByText('Cancel')).toBeInTheDocument()
})
fireEvent.click(screen.getByText('Cancel'))
expect(mockOnCancel).toHaveBeenCalledOnce()
})
it('submits form with correct data', async () => {
render(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
const hostInput = screen.getByPlaceholderText('192.168.1.100')
const portInput = screen.getByDisplayValue('80')
fireEvent.change(domainInput, { target: { value: 'newsite.com' } })
fireEvent.change(hostInput, { target: { value: '10.0.0.1' } })
fireEvent.change(portInput, { target: { value: '9000' } })
fireEvent.click(screen.getByText('Create'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
domain_names: 'newsite.com',
forward_host: '10.0.0.1',
forward_port: 9000,
})
)
})
})
it('handles SSL and WebSocket checkboxes', async () => {
render(
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
await waitFor(() => {
expect(screen.getByLabelText('Force SSL')).toBeInTheDocument()
})
const sslCheckbox = screen.getByLabelText('Force SSL')
const wsCheckbox = screen.getByLabelText('WebSocket Support')
expect(sslCheckbox).not.toBeChecked()
expect(wsCheckbox).not.toBeChecked()
fireEvent.click(sslCheckbox)
fireEvent.click(wsCheckbox)
expect(sslCheckbox).toBeChecked()
expect(wsCheckbox).toBeChecked()
})
})
@@ -0,0 +1,124 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import RemoteServerForm from '../RemoteServerForm'
// Mock the API
vi.mock('../../services/api', () => ({
remoteServersAPI: {
test: vi.fn(() => Promise.resolve({ reachable: true, address: 'localhost:8080' })),
},
}))
describe('RemoteServerForm', () => {
const mockOnSubmit = vi.fn(() => Promise.resolve())
const mockOnCancel = vi.fn()
afterEach(() => {
vi.clearAllMocks()
})
it('renders create form', () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Add Remote Server')).toBeInTheDocument()
expect(screen.getByPlaceholderText('My Production Server')).toHaveValue('')
})
it('renders edit form with pre-filled data', () => {
const mockServer = {
uuid: '123',
name: 'Test Server',
provider: 'docker',
host: 'localhost',
port: 5000,
username: 'admin',
enabled: true,
reachable: true,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
render(
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Edit Remote Server')).toBeInTheDocument()
expect(screen.getByDisplayValue('Test Server')).toBeInTheDocument()
expect(screen.getByDisplayValue('localhost')).toBeInTheDocument()
expect(screen.getByDisplayValue('5000')).toBeInTheDocument()
})
it('shows test connection button only in edit mode', () => {
const { rerender } = render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.queryByText('Test Connection')).not.toBeInTheDocument()
const mockServer = {
uuid: '123',
name: 'Test Server',
provider: 'docker',
host: 'localhost',
port: 5000,
enabled: true,
reachable: false,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
}
rerender(
<RemoteServerForm server={mockServer} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
expect(screen.getByText('Test Connection')).toBeInTheDocument()
})
it('calls onCancel when cancel button is clicked', () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
fireEvent.click(screen.getByText('Cancel'))
expect(mockOnCancel).toHaveBeenCalledOnce()
})
it('submits form with correct data', async () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const nameInput = screen.getByPlaceholderText('My Production Server')
const hostInput = screen.getByPlaceholderText('192.168.1.100')
const portInput = screen.getByDisplayValue('80')
fireEvent.change(nameInput, { target: { value: 'New Server' } })
fireEvent.change(hostInput, { target: { value: '10.0.0.5' } })
fireEvent.change(portInput, { target: { value: '9090' } })
fireEvent.click(screen.getByText('Create'))
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
name: 'New Server',
host: '10.0.0.5',
port: 9090,
})
)
})
})
it('handles provider selection', () => {
render(
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
)
const providerSelect = screen.getByDisplayValue('Generic')
fireEvent.change(providerSelect, { target: { value: 'docker' } })
expect(providerSelect).toHaveValue('docker')
})
})
@@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useImport } from '../useImport'
import * as api from '../../services/api'
// Mock the API
vi.mock('../../services/api', () => ({
importAPI: {
status: vi.fn(),
preview: vi.fn(),
upload: vi.fn(),
commit: vi.fn(),
cancel: vi.fn(),
},
}))
describe('useImport', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.importAPI.status).mockResolvedValue({ has_pending: false })
})
afterEach(() => {
vi.clearAllMocks()
})
it('starts with no active session', async () => {
const { result } = renderHook(() => useImport())
await waitFor(() => {
expect(result.current.session).toBeNull()
})
expect(result.current.loading).toBe(false)
expect(result.current.error).toBeNull()
})
it('uploads content and creates session', async () => {
const mockSession = {
uuid: 'session-1',
filename: 'Caddyfile',
state: 'reviewing',
created_at: '2025-01-18T10:00:00Z',
updated_at: '2025-01-18T10:00:00Z',
}
const mockPreview = {
hosts: [{ domain: 'test.com' }],
conflicts: [],
errors: [],
}
vi.mocked(api.importAPI.upload).mockResolvedValue({ session: mockSession })
vi.mocked(api.importAPI.status).mockResolvedValue({ has_pending: true, session: mockSession })
vi.mocked(api.importAPI.preview).mockResolvedValue(mockPreview)
const { result } = renderHook(() => useImport())
await result.current.upload('example.com { reverse_proxy localhost:8080 }')
await waitFor(() => {
expect(result.current.session).toEqual(mockSession)
})
expect(api.importAPI.upload).toHaveBeenCalledWith('example.com { reverse_proxy localhost:8080 }', undefined)
expect(result.current.loading).toBe(false)
})
it('handles upload errors', async () => {
const mockError = new Error('Upload failed')
vi.mocked(api.importAPI.upload).mockRejectedValue(mockError)
const { result } = renderHook(() => useImport())
await expect(result.current.upload('invalid')).rejects.toThrow('Upload failed')
expect(result.current.error).toBe('Upload failed')
})
it('commits import with resolutions', async () => {
const mockSession = {
uuid: 'session-2',
filename: 'Caddyfile',
state: 'reviewing',
created_at: '2025-01-18T10:00:00Z',
updated_at: '2025-01-18T10:00:00Z',
}
vi.mocked(api.importAPI.upload).mockResolvedValue({ session: mockSession })
vi.mocked(api.importAPI.status)
.mockResolvedValueOnce({ has_pending: true, session: mockSession })
.mockResolvedValueOnce({ has_pending: false })
vi.mocked(api.importAPI.preview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] })
vi.mocked(api.importAPI.commit).mockResolvedValue({})
const { result } = renderHook(() => useImport())
await result.current.upload('test')
await waitFor(() => {
expect(result.current.session).toEqual(mockSession)
})
await result.current.commit({ 'test.com': 'skip' })
expect(api.importAPI.commit).toHaveBeenCalledWith('session-2', { 'test.com': 'skip' })
await waitFor(() => {
expect(result.current.session).toBeNull()
})
})
it('cancels active import session', async () => {
const mockSession = {
uuid: 'session-3',
filename: 'Caddyfile',
state: 'reviewing',
created_at: '2025-01-18T10:00:00Z',
updated_at: '2025-01-18T10:00:00Z',
}
vi.mocked(api.importAPI.upload).mockResolvedValue({ session: mockSession })
vi.mocked(api.importAPI.status).mockResolvedValue({ has_pending: true, session: mockSession })
vi.mocked(api.importAPI.preview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] })
vi.mocked(api.importAPI.cancel).mockResolvedValue(undefined)
const { result } = renderHook(() => useImport())
await result.current.upload('test')
await waitFor(() => {
expect(result.current.session).toEqual(mockSession)
})
await result.current.cancel()
expect(api.importAPI.cancel).toHaveBeenCalledWith('session-3')
expect(result.current.session).toBeNull()
})
it('handles commit errors', async () => {
const mockSession = {
uuid: 'session-4',
filename: 'Caddyfile',
state: 'reviewing',
created_at: '2025-01-18T10:00:00Z',
updated_at: '2025-01-18T10:00:00Z',
}
vi.mocked(api.importAPI.upload).mockResolvedValue({ session: mockSession })
vi.mocked(api.importAPI.status).mockResolvedValue({ has_pending: true, session: mockSession })
vi.mocked(api.importAPI.preview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] })
const mockError = new Error('Commit failed')
vi.mocked(api.importAPI.commit).mockRejectedValue(mockError)
const { result } = renderHook(() => useImport())
await result.current.upload('test')
await waitFor(() => {
expect(result.current.session).toEqual(mockSession)
})
await expect(result.current.commit({})).rejects.toThrow('Commit failed')
expect(result.current.error).toBe('Commit failed')
})
})
@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useProxyHosts } from '../useProxyHosts'
import * as api from '../../services/api'
// Mock the API
vi.mock('../../services/api', () => ({
proxyHostsAPI: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
}))
describe('useProxyHosts', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
it('loads proxy hosts on mount', async () => {
const mockHosts = [
{ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 },
{ uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 },
]
vi.mocked(api.proxyHostsAPI.list).mockResolvedValue(mockHosts)
const { result } = renderHook(() => useProxyHosts())
expect(result.current.loading).toBe(true)
expect(result.current.hosts).toEqual([])
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.hosts).toEqual(mockHosts)
expect(result.current.error).toBeNull()
expect(api.proxyHostsAPI.list).toHaveBeenCalledOnce()
})
it('handles loading errors', async () => {
const mockError = new Error('Failed to fetch')
vi.mocked(api.proxyHostsAPI.list).mockRejectedValue(mockError)
const { result } = renderHook(() => useProxyHosts())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.error).toBe('Failed to fetch')
expect(result.current.hosts).toEqual([])
})
it('creates a new proxy host', async () => {
vi.mocked(api.proxyHostsAPI.list).mockResolvedValue([])
const newHost = { domain_names: 'new.com', forward_host: 'localhost', forward_port: 9000 }
const createdHost = { uuid: '3', ...newHost, enabled: true }
vi.mocked(api.proxyHostsAPI.create).mockResolvedValue(createdHost)
const { result } = renderHook(() => useProxyHosts())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await result.current.createHost(newHost)
expect(api.proxyHostsAPI.create).toHaveBeenCalledWith(newHost)
expect(api.proxyHostsAPI.list).toHaveBeenCalledTimes(2) // Initial load + reload after create
})
it('updates an existing proxy host', async () => {
const existingHost = { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }
vi.mocked(api.proxyHostsAPI.list).mockResolvedValue([existingHost])
const updatedHost = { ...existingHost, domain_names: 'updated.com' }
vi.mocked(api.proxyHostsAPI.update).mockResolvedValue(updatedHost)
const { result } = renderHook(() => useProxyHosts())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await result.current.updateHost('1', { domain_names: 'updated.com' })
expect(api.proxyHostsAPI.update).toHaveBeenCalledWith('1', { domain_names: 'updated.com' })
expect(api.proxyHostsAPI.list).toHaveBeenCalledTimes(2)
})
it('deletes a proxy host', async () => {
const hosts = [
{ uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 },
{ uuid: '2', domain_names: 'app.com', enabled: true, forward_host: 'localhost', forward_port: 3000 },
]
vi.mocked(api.proxyHostsAPI.list).mockResolvedValue(hosts)
vi.mocked(api.proxyHostsAPI.delete).mockResolvedValue(undefined)
const { result } = renderHook(() => useProxyHosts())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await result.current.deleteHost('1')
expect(api.proxyHostsAPI.delete).toHaveBeenCalledWith('1')
expect(api.proxyHostsAPI.list).toHaveBeenCalledTimes(2)
})
it('handles create errors', async () => {
vi.mocked(api.proxyHostsAPI.list).mockResolvedValue([])
const mockError = new Error('Failed to create')
vi.mocked(api.proxyHostsAPI.create).mockRejectedValue(mockError)
const { result } = renderHook(() => useProxyHosts())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await expect(result.current.createHost({ domain_names: 'test.com', forward_host: 'localhost', forward_port: 8080 })).rejects.toThrow('Failed to create')
})
it('handles update errors', async () => {
const host = { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }
vi.mocked(api.proxyHostsAPI.list).mockResolvedValue([host])
const mockError = new Error('Failed to update')
vi.mocked(api.proxyHostsAPI.update).mockRejectedValue(mockError)
const { result } = renderHook(() => useProxyHosts())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await expect(result.current.updateHost('1', { domain_names: 'updated.com' })).rejects.toThrow('Failed to update')
})
it('handles delete errors', async () => {
const host = { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }
vi.mocked(api.proxyHostsAPI.list).mockResolvedValue([host])
const mockError = new Error('Failed to delete')
vi.mocked(api.proxyHostsAPI.delete).mockRejectedValue(mockError)
const { result } = renderHook(() => useProxyHosts())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await expect(result.current.deleteHost('1')).rejects.toThrow('Failed to delete')
})
})
@@ -0,0 +1,218 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useRemoteServers } from '../useRemoteServers'
import * as api from '../../services/api'
// Mock the API
vi.mock('../../services/api', () => ({
remoteServersAPI: {
list: vi.fn(),
get: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
test: vi.fn(),
},
}))
describe('useRemoteServers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
it('loads all remote servers on mount', async () => {
const mockServers = [
{ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true },
{ uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false },
]
vi.mocked(api.remoteServersAPI.list).mockResolvedValue(mockServers)
const { result } = renderHook(() => useRemoteServers())
expect(result.current.loading).toBe(true)
expect(result.current.servers).toEqual([])
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.servers).toEqual(mockServers)
expect(result.current.error).toBeNull()
expect(api.remoteServersAPI.list).toHaveBeenCalledOnce()
})
it('filters enabled servers', async () => {
const mockServers = [
{ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true },
{ uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false },
{ uuid: '3', name: 'Server 3', host: '10.0.0.1', port: 9000, enabled: true },
]
vi.mocked(api.remoteServersAPI.list).mockResolvedValue(mockServers)
const { result } = renderHook(() => useRemoteServers())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.enabledServers).toHaveLength(2)
expect(result.current.enabledServers).toEqual([
mockServers[0],
mockServers[2],
])
})
it('handles loading errors', async () => {
const mockError = new Error('Network error')
vi.mocked(api.remoteServersAPI.list).mockRejectedValue(mockError)
const { result } = renderHook(() => useRemoteServers())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.error).toBe('Network error')
expect(result.current.servers).toEqual([])
expect(result.current.enabledServers).toEqual([])
})
it('creates a new remote server', async () => {
vi.mocked(api.remoteServersAPI.list).mockResolvedValue([])
const newServer = { name: 'New Server', host: 'new.local', port: 5000, enabled: true, provider: 'generic' }
const createdServer = { uuid: '4', ...newServer }
vi.mocked(api.remoteServersAPI.create).mockResolvedValue(createdServer)
const { result } = renderHook(() => useRemoteServers())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await result.current.createServer(newServer)
expect(api.remoteServersAPI.create).toHaveBeenCalledWith(newServer)
expect(api.remoteServersAPI.list).toHaveBeenCalledTimes(2)
})
it('updates an existing remote server', async () => {
const existingServer = { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }
vi.mocked(api.remoteServersAPI.list).mockResolvedValue([existingServer])
const updatedServer = { ...existingServer, name: 'Updated Server' }
vi.mocked(api.remoteServersAPI.update).mockResolvedValue(updatedServer)
const { result } = renderHook(() => useRemoteServers())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await result.current.updateServer('1', { name: 'Updated Server' })
expect(api.remoteServersAPI.update).toHaveBeenCalledWith('1', { name: 'Updated Server' })
expect(api.remoteServersAPI.list).toHaveBeenCalledTimes(2)
})
it('deletes a remote server', async () => {
const servers = [
{ uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true },
{ uuid: '2', name: 'Server 2', host: '192.168.1.100', port: 3000, enabled: false },
]
vi.mocked(api.remoteServersAPI.list).mockResolvedValue(servers)
vi.mocked(api.remoteServersAPI.delete).mockResolvedValue(undefined)
const { result } = renderHook(() => useRemoteServers())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await result.current.deleteServer('1')
expect(api.remoteServersAPI.delete).toHaveBeenCalledWith('1')
expect(api.remoteServersAPI.list).toHaveBeenCalledTimes(2)
})
it('tests server connection', async () => {
vi.mocked(api.remoteServersAPI.list).mockResolvedValue([])
const testResult = { reachable: true, address: 'localhost:8080' }
vi.mocked(api.remoteServersAPI.test).mockResolvedValue(testResult)
const { result } = renderHook(() => useRemoteServers())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
const response = await result.current.testConnection('1')
expect(api.remoteServersAPI.test).toHaveBeenCalledWith('1')
expect(response).toEqual(testResult)
})
it('handles create errors', async () => {
vi.mocked(api.remoteServersAPI.list).mockResolvedValue([])
const mockError = new Error('Failed to create')
vi.mocked(api.remoteServersAPI.create).mockRejectedValue(mockError)
const { result } = renderHook(() => useRemoteServers())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await expect(result.current.createServer({ name: 'Test', host: 'localhost', port: 8080 })).rejects.toThrow('Failed to create')
})
it('handles update errors', async () => {
const server = { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }
vi.mocked(api.remoteServersAPI.list).mockResolvedValue([server])
const mockError = new Error('Failed to update')
vi.mocked(api.remoteServersAPI.update).mockRejectedValue(mockError)
const { result } = renderHook(() => useRemoteServers())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await expect(result.current.updateServer('1', { name: 'Updated' })).rejects.toThrow('Failed to update')
})
it('handles delete errors', async () => {
const server = { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }
vi.mocked(api.remoteServersAPI.list).mockResolvedValue([server])
const mockError = new Error('Failed to delete')
vi.mocked(api.remoteServersAPI.delete).mockRejectedValue(mockError)
const { result } = renderHook(() => useRemoteServers())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await expect(result.current.deleteServer('1')).rejects.toThrow('Failed to delete')
})
it('handles connection test errors', async () => {
vi.mocked(api.remoteServersAPI.list).mockResolvedValue([])
const mockError = new Error('Connection failed')
vi.mocked(api.remoteServersAPI.test).mockRejectedValue(mockError)
const { result } = renderHook(() => useRemoteServers())
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
await expect(result.current.testConnection('1')).rejects.toThrow('Connection failed')
})
})
+116
View File
@@ -0,0 +1,116 @@
import { useState, useEffect, useCallback } from 'react'
import { importAPI } from '../services/api'
interface ImportSession {
uuid: string
filename?: string
state: string
created_at: string
updated_at: string
}
interface ImportPreview {
hosts: any[]
conflicts: string[]
errors: string[]
}
export function useImport() {
const [session, setSession] = useState<ImportSession | null>(null)
const [preview, setPreview] = useState<ImportPreview | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [polling, setPolling] = useState(false)
const checkStatus = useCallback(async () => {
try {
const status = await importAPI.status()
if (status.has_pending && status.session) {
setSession(status.session)
if (status.session.state === 'reviewing') {
const previewData = await importAPI.preview()
setPreview(previewData)
}
} else {
setSession(null)
setPreview(null)
}
} catch (err) {
console.error('Failed to check import status:', err)
}
}, [])
useEffect(() => {
checkStatus()
}, [checkStatus])
useEffect(() => {
if (polling && session?.state === 'reviewing') {
const interval = setInterval(checkStatus, 3000)
return () => clearInterval(interval)
}
}, [polling, session?.state, checkStatus])
const upload = async (content: string, filename?: string) => {
try {
setLoading(true)
setError(null)
const result = await importAPI.upload(content, filename)
setSession(result.session)
setPolling(true)
await checkStatus()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to upload Caddyfile')
throw err
} finally {
setLoading(false)
}
}
const commit = async (resolutions: Record<string, string>) => {
if (!session) throw new Error('No active session')
try {
setLoading(true)
setError(null)
await importAPI.commit(session.uuid, resolutions)
setSession(null)
setPreview(null)
setPolling(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to commit import')
throw err
} finally {
setLoading(false)
}
}
const cancel = async () => {
if (!session) return
try {
setLoading(true)
setError(null)
await importAPI.cancel(session.uuid)
setSession(null)
setPreview(null)
setPolling(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel import')
throw err
} finally {
setLoading(false)
}
}
return {
session,
preview,
loading,
error,
upload,
commit,
cancel,
refresh: checkStatus,
}
}
+84
View File
@@ -0,0 +1,84 @@
import { useState, useEffect } from 'react'
import { proxyHostsAPI } from '../services/api'
export interface ProxyHost {
uuid: string
domain_names: string
forward_scheme: string
forward_host: string
forward_port: number
access_list_id?: string
certificate_id?: string
ssl_forced: boolean
http2_support: boolean
hsts_enabled: boolean
hsts_subdomains: boolean
block_exploits: boolean
websocket_support: boolean
advanced_config?: string
enabled: boolean
created_at: string
updated_at: string
}
export function useProxyHosts() {
const [hosts, setHosts] = useState<ProxyHost[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchHosts = async () => {
try {
setLoading(true)
setError(null)
const data = await proxyHostsAPI.list()
setHosts(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch proxy hosts')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchHosts()
}, [])
const createHost = async (data: Partial<ProxyHost>) => {
try {
const newHost = await proxyHostsAPI.create(data)
setHosts([...hosts, newHost])
return newHost
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to create proxy host')
}
}
const updateHost = async (uuid: string, data: Partial<ProxyHost>) => {
try {
const updatedHost = await proxyHostsAPI.update(uuid, data)
setHosts(hosts.map(h => h.uuid === uuid ? updatedHost : h))
return updatedHost
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to update proxy host')
}
}
const deleteHost = async (uuid: string) => {
try {
await proxyHostsAPI.delete(uuid)
setHosts(hosts.filter(h => h.uuid !== uuid))
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to delete proxy host')
}
}
return {
hosts,
loading,
error,
refresh: fetchHosts,
createHost,
updateHost,
deleteHost,
}
}
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react'
import { remoteServersAPI } from '../services/api'
export interface RemoteServer {
uuid: string
name: string
provider: string
host: string
port: number
username?: string
enabled: boolean
reachable: boolean
last_check?: string
created_at: string
updated_at: string
}
export function useRemoteServers() {
const [servers, setServers] = useState<RemoteServer[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchServers = async (enabledOnly = false) => {
try {
setLoading(true)
setError(null)
const data = await remoteServersAPI.list(enabledOnly)
setServers(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch remote servers')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchServers()
}, [])
const createServer = async (data: Partial<RemoteServer>) => {
try {
const newServer = await remoteServersAPI.create(data)
setServers([...servers, newServer])
return newServer
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to create remote server')
}
}
const updateServer = async (uuid: string, data: Partial<RemoteServer>) => {
try {
const updatedServer = await remoteServersAPI.update(uuid, data)
setServers(servers.map(s => s.uuid === uuid ? updatedServer : s))
return updatedServer
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to update remote server')
}
}
const deleteServer = async (uuid: string) => {
try {
await remoteServersAPI.delete(uuid)
setServers(servers.filter(s => s.uuid !== uuid))
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to delete remote server')
}
}
return {
servers,
loading,
error,
refresh: fetchServers,
createServer,
updateServer,
deleteServer,
}
}
+45
View File
@@ -0,0 +1,45 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
}
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
color: rgba(255, 255, 255, 0.87);
background-color: #0f172a;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react'
import { useProxyHosts } from '../hooks/useProxyHosts'
import { useRemoteServers } from '../hooks/useRemoteServers'
import { healthAPI } from '../services/api'
import { Link } from 'react-router-dom'
export default function Dashboard() {
const { hosts } = useProxyHosts()
const { servers } = useRemoteServers()
const [health, setHealth] = useState<{ status: string } | null>(null)
useEffect(() => {
const checkHealth = async () => {
try {
const result = await healthAPI.check()
setHealth(result)
} catch (err) {
setHealth({ status: 'error' })
}
}
checkHealth()
}, [])
const enabledHosts = hosts.filter(h => h.enabled).length
const enabledServers = servers.filter(s => s.enabled).length
return (
<div className="p-8">
<h1 className="text-3xl font-bold text-white mb-6">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<Link to="/proxy-hosts" className="bg-dark-card p-6 rounded-lg border border-gray-800 hover:border-gray-700 transition-colors">
<div className="text-sm text-gray-400 mb-2">Proxy Hosts</div>
<div className="text-3xl font-bold text-white mb-1">{hosts.length}</div>
<div className="text-xs text-gray-500">{enabledHosts} enabled</div>
</Link>
<Link to="/remote-servers" className="bg-dark-card p-6 rounded-lg border border-gray-800 hover:border-gray-700 transition-colors">
<div className="text-sm text-gray-400 mb-2">Remote Servers</div>
<div className="text-3xl font-bold text-white mb-1">{servers.length}</div>
<div className="text-xs text-gray-500">{enabledServers} enabled</div>
</Link>
<div className="bg-dark-card p-6 rounded-lg border border-gray-800">
<div className="text-sm text-gray-400 mb-2">SSL Certificates</div>
<div className="text-3xl font-bold text-white mb-1">0</div>
<div className="text-xs text-gray-500">Coming soon</div>
</div>
<div className="bg-dark-card p-6 rounded-lg border border-gray-800">
<div className="text-sm text-gray-400 mb-2">System Status</div>
<div className={`text-lg font-bold ${health?.status === 'ok' ? 'text-green-400' : 'text-red-400'}`}>
{health?.status === 'ok' ? 'Healthy' : health ? 'Error' : 'Checking...'}
</div>
</div>
</div>
{/* Quick Actions */}
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Link
to="/proxy-hosts"
className="flex items-center gap-3 p-4 bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
>
<span className="text-2xl">🌐</span>
<div>
<div className="font-medium text-white">Add Proxy Host</div>
<div className="text-xs text-gray-400">Create a new reverse proxy</div>
</div>
</Link>
<Link
to="/remote-servers"
className="flex items-center gap-3 p-4 bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
>
<span className="text-2xl">🖥</span>
<div>
<div className="font-medium text-white">Add Remote Server</div>
<div className="text-xs text-gray-400">Register a backend server</div>
</div>
</Link>
<Link
to="/import"
className="flex items-center gap-3 p-4 bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors"
>
<span className="text-2xl">📥</span>
<div>
<div className="font-medium text-white">Import Caddyfile</div>
<div className="text-xs text-gray-400">Bulk import from existing config</div>
</div>
</Link>
</div>
</div>
</div>
)
}
+145
View File
@@ -0,0 +1,145 @@
import { useState } from 'react'
import { useImport } from '../hooks/useImport'
import ImportBanner from '../components/ImportBanner'
import ImportReviewTable from '../components/ImportReviewTable'
export default function ImportCaddy() {
const { session, preview, loading, error, upload, commit, cancel } = useImport()
const [content, setContent] = useState('')
const [showReview, setShowReview] = useState(false)
const handleUpload = async () => {
if (!content.trim()) {
alert('Please enter Caddyfile content')
return
}
try {
await upload(content)
setShowReview(true)
} catch (err) {
// Error is already set by hook
}
}
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const text = await file.text()
setContent(text)
}
const handleCommit = async (resolutions: Record<string, string>) => {
try {
await commit(resolutions)
setContent('')
setShowReview(false)
alert('Import completed successfully!')
} catch (err) {
// Error is already set by hook
}
}
const handleCancel = async () => {
if (confirm('Are you sure you want to cancel this import?')) {
try {
await cancel()
setShowReview(false)
} catch (err) {
// Error is already set by hook
}
}
}
return (
<div className="p-8">
<h1 className="text-3xl font-bold text-white mb-6">Import Caddyfile</h1>
{session && (
<ImportBanner
session={session}
onReview={() => setShowReview(true)}
onCancel={handleCancel}
/>
)}
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
{error}
</div>
)}
{!session && (
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">Upload or Paste Caddyfile</h2>
<p className="text-gray-400 text-sm">
Import an existing Caddyfile to automatically create proxy host configurations.
The system will detect conflicts and allow you to review changes before committing.
</p>
</div>
<div className="space-y-4">
{/* File Upload */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Upload Caddyfile
</label>
<input
type="file"
accept=".caddyfile,.txt,text/plain"
onChange={handleFileUpload}
className="w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-blue-active file:text-white hover:file:bg-blue-hover file:cursor-pointer cursor-pointer"
/>
</div>
{/* Or Divider */}
<div className="flex items-center gap-4">
<div className="flex-1 border-t border-gray-700" />
<span className="text-gray-500 text-sm">or paste content</span>
<div className="flex-1 border-t border-gray-700" />
</div>
{/* Text Area */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Caddyfile Content
</label>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
className="w-full h-96 bg-gray-900 border border-gray-700 rounded-lg p-4 text-white font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={`example.com {
reverse_proxy localhost:8080
}
api.example.com {
reverse_proxy localhost:3000
}`}
/>
</div>
<button
onClick={handleUpload}
disabled={loading || !content.trim()}
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? 'Processing...' : 'Parse and Review'}
</button>
</div>
</div>
)}
{showReview && preview && (
<ImportReviewTable
hosts={preview.hosts}
conflicts={preview.conflicts}
errors={preview.errors}
onCommit={handleCommit}
onCancel={() => setShowReview(false)}
/>
)}
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
import { useState } from 'react'
import { useProxyHosts, ProxyHost } from '../hooks/useProxyHosts'
import ProxyHostForm from '../components/ProxyHostForm'
export default function ProxyHosts() {
const { hosts, loading, error, createHost, updateHost, deleteHost } = useProxyHosts()
const [showForm, setShowForm] = useState(false)
const [editingHost, setEditingHost] = useState<ProxyHost | undefined>()
const handleAdd = () => {
setEditingHost(undefined)
setShowForm(true)
}
const handleEdit = (host: ProxyHost) => {
setEditingHost(host)
setShowForm(true)
}
const handleSubmit = async (data: Partial<ProxyHost>) => {
if (editingHost) {
await updateHost(editingHost.uuid, data)
} else {
await createHost(data)
}
setShowForm(false)
setEditingHost(undefined)
}
const handleDelete = async (uuid: string) => {
if (confirm('Are you sure you want to delete this proxy host?')) {
try {
await deleteHost(uuid)
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete')
}
}
}
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-white">Proxy Hosts</h1>
<button
onClick={handleAdd}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
>
Add Proxy Host
</button>
</div>
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
{error}
</div>
)}
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
{loading ? (
<div className="text-center text-gray-400 py-12">Loading...</div>
) : hosts.length === 0 ? (
<div className="text-center text-gray-400 py-12">
No proxy hosts configured yet. Click "Add Proxy Host" to get started.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Domain
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Forward To
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
SSL
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{hosts.map((host) => (
<tr key={host.uuid} className="hover:bg-gray-900/50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-white">{host.domain_names}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-300">
{host.forward_scheme}://{host.forward_host}:{host.forward_port}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex gap-2">
{host.ssl_forced && (
<span className="px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
SSL
</span>
)}
{host.websocket_support && (
<span className="px-2 py-1 text-xs bg-blue-900/30 text-blue-400 rounded">
WS
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs rounded ${
host.enabled
? 'bg-green-900/30 text-green-400'
: 'bg-gray-700 text-gray-400'
}`}
>
{host.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(host)}
className="text-blue-400 hover:text-blue-300 mr-4"
>
Edit
</button>
<button
onClick={() => handleDelete(host.uuid)}
className="text-red-400 hover:text-red-300"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{showForm && (
<ProxyHostForm
host={editingHost}
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false)
setEditingHost(undefined)
}}
/>
)}
</div>
)
}
+234
View File
@@ -0,0 +1,234 @@
import { useState } from 'react'
import { useRemoteServers, RemoteServer } from '../hooks/useRemoteServers'
import RemoteServerForm from '../components/RemoteServerForm'
export default function RemoteServers() {
const { servers, loading, error, createServer, updateServer, deleteServer } = useRemoteServers()
const [showForm, setShowForm] = useState(false)
const [editingServer, setEditingServer] = useState<RemoteServer | undefined>()
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
const handleAdd = () => {
setEditingServer(undefined)
setShowForm(true)
}
const handleEdit = (server: RemoteServer) => {
setEditingServer(server)
setShowForm(true)
}
const handleSubmit = async (data: Partial<RemoteServer>) => {
if (editingServer) {
await updateServer(editingServer.uuid, data)
} else {
await createServer(data)
}
setShowForm(false)
setEditingServer(undefined)
}
const handleDelete = async (uuid: string) => {
if (confirm('Are you sure you want to delete this remote server?')) {
try {
await deleteServer(uuid)
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete')
}
}
}
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-white">Remote Servers</h1>
<div className="flex gap-3">
<div className="flex bg-gray-800 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'grid'
? 'bg-blue-active text-white'
: 'text-gray-400 hover:text-white'
}`}
>
Grid
</button>
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1 rounded text-sm ${
viewMode === 'list'
? 'bg-blue-active text-white'
: 'text-gray-400 hover:text-white'
}`}
>
List
</button>
</div>
<button
onClick={handleAdd}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
>
Add Server
</button>
</div>
</div>
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded mb-6">
{error}
</div>
)}
{loading ? (
<div className="text-center text-gray-400 py-12">Loading...</div>
) : servers.length === 0 ? (
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
<div className="text-center text-gray-400 py-12">
No remote servers configured. Add servers to quickly select backends when creating proxy hosts.
</div>
</div>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{servers.map((server) => (
<div
key={server.uuid}
className="bg-dark-card rounded-lg border border-gray-800 p-6 hover:border-gray-700 transition-colors"
>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-white mb-1">{server.name}</h3>
<span className="inline-block px-2 py-1 text-xs bg-gray-800 text-gray-400 rounded">
{server.provider}
</span>
</div>
<span
className={`px-2 py-1 text-xs rounded ${
server.enabled
? 'bg-green-900/30 text-green-400'
: 'bg-gray-700 text-gray-400'
}`}
>
{server.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-400">Host:</span>
<span className="text-white font-mono">{server.host}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-400">Port:</span>
<span className="text-white font-mono">{server.port}</span>
</div>
{server.username && (
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-400">User:</span>
<span className="text-white font-mono">{server.username}</span>
</div>
)}
</div>
<div className="flex gap-2 pt-4 border-t border-gray-800">
<button
onClick={() => handleEdit(server)}
className="flex-1 px-3 py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm rounded-lg font-medium transition-colors"
>
Edit
</button>
<button
onClick={() => handleDelete(server.uuid)}
className="flex-1 px-3 py-2 bg-red-900/20 hover:bg-red-900/30 text-red-400 text-sm rounded-lg font-medium transition-colors"
>
Delete
</button>
</div>
</div>
))}
</div>
) : (
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Provider
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Host
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Port
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{servers.map((server) => (
<tr key={server.uuid} className="hover:bg-gray-900/50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-white">{server.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 py-1 text-xs bg-gray-800 text-gray-400 rounded">
{server.provider}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-300 font-mono">{server.host}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-300 font-mono">{server.port}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs rounded ${
server.enabled
? 'bg-green-900/30 text-green-400'
: 'bg-gray-700 text-gray-400'
}`}
>
{server.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(server)}
className="text-blue-400 hover:text-blue-300 mr-4"
>
Edit
</button>
<button
onClick={() => handleDelete(server.uuid)}
className="text-red-400 hover:text-red-300"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showForm && (
<RemoteServerForm
server={editingServer}
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false)
setEditingServer(undefined)
}}
/>
)}
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
export default function Settings() {
return (
<div className="p-8">
<h1 className="text-3xl font-bold text-white mb-6">Settings</h1>
<div className="bg-dark-card rounded-lg border border-gray-800 p-6">
<div className="text-gray-400">
Settings page coming soon...
</div>
</div>
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
const API_BASE = '/api/v1'
interface RequestOptions {
method?: string
headers?: Record<string, string>
body?: any
}
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const url = `${API_BASE}${endpoint}`
const config: RequestInit = {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
}
if (options.body) {
config.body = JSON.stringify(options.body)
}
const response = await fetch(url, config)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || `HTTP ${response.status}`)
}
return response.json()
}
// Proxy Hosts API
export const proxyHostsAPI = {
list: () => request<any[]>('/proxy-hosts'),
get: (uuid: string) => request<any>(`/proxy-hosts/${uuid}`),
create: (data: any) => request<any>('/proxy-hosts', { method: 'POST', body: data }),
update: (uuid: string, data: any) => request<any>(`/proxy-hosts/${uuid}`, { method: 'PUT', body: data }),
delete: (uuid: string) => request<void>(`/proxy-hosts/${uuid}`, { method: 'DELETE' }),
}
// Remote Servers API
export const remoteServersAPI = {
list: (enabledOnly?: boolean) => {
const query = enabledOnly ? '?enabled=true' : ''
return request<any[]>(`/remote-servers${query}`)
},
get: (uuid: string) => request<any>(`/remote-servers/${uuid}`),
create: (data: any) => request<any>('/remote-servers', { method: 'POST', body: data }),
update: (uuid: string, data: any) => request<any>(`/remote-servers/${uuid}`, { method: 'PUT', body: data }),
delete: (uuid: string) => request<void>(`/remote-servers/${uuid}`, { method: 'DELETE' }),
test: (uuid: string) => request<any>(`/remote-servers/${uuid}/test`, { method: 'POST' }),
}
// Import API
export const importAPI = {
status: () => request<{ has_pending: boolean; session?: any }>('/import/status'),
preview: () => request<{ hosts: any[]; conflicts: string[]; errors: string[] }>('/import/preview'),
upload: (content: string, filename?: string) => request<any>('/import/upload', {
method: 'POST',
body: { content, filename }
}),
commit: (sessionUUID: string, resolutions: Record<string, string>) => request<any>('/import/commit', {
method: 'POST',
body: { session_uuid: sessionUUID, resolutions }
}),
cancel: (sessionUUID: string) => request<void>(`/import/cancel?session_uuid=${sessionUUID}`, { method: 'DELETE' }),
}
// Health API
export const healthAPI = {
check: () => request<{ status: string }>('/health'),
}
+88
View File
@@ -0,0 +1,88 @@
import { ProxyHost } from '../hooks/useProxyHosts'
import { RemoteServer } from '../hooks/useRemoteServers'
export const mockProxyHosts: ProxyHost[] = [
{
uuid: '123e4567-e89b-12d3-a456-426614174000',
domain_names: 'app.local.dev',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 3000,
access_list_id: undefined,
certificate_id: undefined,
ssl_forced: false,
http2_support: true,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: true,
websocket_support: true,
advanced_config: undefined,
enabled: true,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
},
{
uuid: '223e4567-e89b-12d3-a456-426614174001',
domain_names: 'api.local.dev',
forward_scheme: 'http',
forward_host: '192.168.1.100',
forward_port: 8080,
access_list_id: undefined,
certificate_id: undefined,
ssl_forced: false,
http2_support: true,
hsts_enabled: false,
hsts_subdomains: false,
block_exploits: true,
websocket_support: false,
advanced_config: undefined,
enabled: true,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
},
]
export const mockRemoteServers: RemoteServer[] = [
{
uuid: '323e4567-e89b-12d3-a456-426614174002',
name: 'Local Docker Registry',
provider: 'docker',
host: 'localhost',
port: 5000,
username: undefined,
enabled: true,
reachable: false,
last_check: undefined,
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
},
{
uuid: '423e4567-e89b-12d3-a456-426614174003',
name: 'Development API Server',
provider: 'generic',
host: '192.168.1.100',
port: 8080,
username: undefined,
enabled: true,
reachable: true,
last_check: '2025-11-18T10:00:00Z',
created_at: '2025-11-18T10:00:00Z',
updated_at: '2025-11-18T10:00:00Z',
},
]
export const mockImportPreview = {
hosts: [
{
domain_names: 'test.example.com',
forward_scheme: 'http',
forward_host: 'localhost',
forward_port: 8080,
ssl_forced: true,
http2_support: true,
websocket_support: false,
},
],
conflicts: ['app.local.dev'],
errors: [],
}
+23
View File
@@ -0,0 +1,23 @@
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// Cleanup after each test
afterEach(() => {
cleanup()
})
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => {},
}),
})