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:
44
frontend/src/components/ImportBanner.tsx
Normal file
44
frontend/src/components/ImportBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
frontend/src/components/ImportReviewTable.tsx
Normal file
172
frontend/src/components/ImportReviewTable.tsx
Normal file
@@ -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
frontend/src/components/Layout.tsx
Normal file
58
frontend/src/components/Layout.tsx
Normal 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
frontend/src/components/LoadingStates.tsx
Normal file
60
frontend/src/components/LoadingStates.tsx
Normal 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
frontend/src/components/ProxyHostForm.tsx
Normal file
270
frontend/src/components/ProxyHostForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
210
frontend/src/components/RemoteServerForm.tsx
Normal file
210
frontend/src/components/RemoteServerForm.tsx
Normal file
@@ -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
frontend/src/components/Toast.tsx
Normal file
86
frontend/src/components/Toast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
frontend/src/components/__tests__/ImportReviewTable.test.tsx
Normal file
160
frontend/src/components/__tests__/ImportReviewTable.test.tsx
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
58
frontend/src/components/__tests__/Layout.test.tsx
Normal file
58
frontend/src/components/__tests__/Layout.test.tsx
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
130
frontend/src/components/__tests__/ProxyHostForm.test.tsx
Normal file
130
frontend/src/components/__tests__/ProxyHostForm.test.tsx
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
124
frontend/src/components/__tests__/RemoteServerForm.test.tsx
Normal file
124
frontend/src/components/__tests__/RemoteServerForm.test.tsx
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user