Remove Settings and Setup pages along with their tests and related API services
- Deleted Settings.tsx and Setup.tsx pages, which included functionality for changing passwords and setting up an admin account. - Removed associated test files for Setup page. - Eliminated API service definitions related to proxy hosts, remote servers, import functionality, and health checks. - Cleaned up mock data and test setup files. - Removed configuration files for TypeScript, Vite, and Tailwind CSS. - Deleted scripts for testing coverage, release management, Dockerfile validation, and Python compilation checks. - Removed Sourcery pre-commit wrapper script.
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
import { BrowserRouter as Router, Routes, Route, Outlet } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import { ToastContainer } from './components/Toast'
|
||||
import { SetupGuard } from './components/SetupGuard'
|
||||
import RequireAuth from './components/RequireAuth'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import ProxyHosts from './pages/ProxyHosts'
|
||||
import RemoteServers from './pages/RemoteServers'
|
||||
import ImportCaddy from './pages/ImportCaddy'
|
||||
import Certificates from './pages/Certificates'
|
||||
import Settings from './pages/Settings'
|
||||
import Login from './pages/Login'
|
||||
import Setup from './pages/Setup'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/setup" element={<Setup />} />
|
||||
<Route path="/" element={
|
||||
<SetupGuard>
|
||||
<RequireAuth>
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
</RequireAuth>
|
||||
</SetupGuard>
|
||||
}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="proxy-hosts" element={<ProxyHosts />} />
|
||||
<Route path="remote-servers" element={<RemoteServers />} />
|
||||
<Route path="certificates" element={<Certificates />} />
|
||||
<Route path="import" element={<ImportCaddy />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<ToastContainer />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import client from './client'
|
||||
|
||||
export interface Certificate {
|
||||
domain: string
|
||||
issuer: string
|
||||
expires_at: string
|
||||
status: 'valid' | 'expiring' | 'expired'
|
||||
}
|
||||
|
||||
export async function getCertificates(): Promise<Certificate[]> {
|
||||
const response = await client.get<Certificate[]>('/certificates')
|
||||
return response.data
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1'
|
||||
});
|
||||
|
||||
export default client;
|
||||
@@ -1,11 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
service: string;
|
||||
}
|
||||
|
||||
export const checkHealth = async (): Promise<HealthResponse> => {
|
||||
const { data } = await client.get<HealthResponse>('/health');
|
||||
return data;
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface ImportSession {
|
||||
id: string;
|
||||
state: 'pending' | 'reviewing' | 'completed' | 'failed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ImportPreview {
|
||||
session: ImportSession;
|
||||
preview: {
|
||||
hosts: Array<{ domain_names: string; [key: string]: unknown }>;
|
||||
conflicts: Record<string, string>;
|
||||
errors: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const uploadCaddyfile = async (content: string): Promise<ImportPreview> => {
|
||||
const { data } = await client.post<ImportPreview>('/import/upload', { content });
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getImportPreview = async (): Promise<ImportPreview> => {
|
||||
const { data } = await client.get<ImportPreview>('/import/preview');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const commitImport = async (resolutions: Record<string, string>): Promise<void> => {
|
||||
await client.post('/import/commit', { resolutions });
|
||||
};
|
||||
|
||||
export const cancelImport = async (): Promise<void> => {
|
||||
await client.post('/import/cancel');
|
||||
};
|
||||
|
||||
export const getImportStatus = async (): Promise<{ has_pending: boolean; session?: ImportSession }> => {
|
||||
// Note: Assuming there might be a status endpoint or we infer from preview.
|
||||
// If no dedicated status endpoint exists in backend, we might rely on preview returning 404 or empty.
|
||||
// Based on previous context, there wasn't an explicit status endpoint mentioned in the simple API,
|
||||
// but the hook used `importAPI.status()`. I'll check the backend routes if needed.
|
||||
// For now, I'll implement it assuming /import/preview can serve as status check or there is a /import/status.
|
||||
// Let's check the backend routes to be sure.
|
||||
try {
|
||||
const { data } = await client.get<{ has_pending: boolean; session?: ImportSession }>('/import/status');
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Fallback if status endpoint doesn't exist, though the hook used it.
|
||||
return { has_pending: false };
|
||||
}
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface Location {
|
||||
uuid?: string;
|
||||
path: string;
|
||||
forward_scheme: string;
|
||||
forward_host: string;
|
||||
forward_port: number;
|
||||
}
|
||||
|
||||
export interface ProxyHost {
|
||||
uuid: string;
|
||||
domain_names: string;
|
||||
forward_scheme: string;
|
||||
forward_host: string;
|
||||
forward_port: number;
|
||||
ssl_forced: boolean;
|
||||
http2_support: boolean;
|
||||
hsts_enabled: boolean;
|
||||
hsts_subdomains: boolean;
|
||||
block_exploits: boolean;
|
||||
websocket_support: boolean;
|
||||
locations: Location[];
|
||||
advanced_config?: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const getProxyHosts = async (): Promise<ProxyHost[]> => {
|
||||
const { data } = await client.get<ProxyHost[]>('/proxy-hosts');
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getProxyHost = async (uuid: string): Promise<ProxyHost> => {
|
||||
const { data } = await client.get<ProxyHost>(`/proxy-hosts/${uuid}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createProxyHost = async (host: Partial<ProxyHost>): Promise<ProxyHost> => {
|
||||
const { data } = await client.post<ProxyHost>('/proxy-hosts', host);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateProxyHost = async (uuid: string, host: Partial<ProxyHost>): Promise<ProxyHost> => {
|
||||
const { data } = await client.put<ProxyHost>(`/proxy-hosts/${uuid}`, host);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteProxyHost = async (uuid: string): Promise<void> => {
|
||||
await client.delete(`/proxy-hosts/${uuid}`);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
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 const getRemoteServers = async (enabledOnly = false): Promise<RemoteServer[]> => {
|
||||
const params = enabledOnly ? { enabled: true } : {};
|
||||
const { data } = await client.get<RemoteServer[]>('/remote-servers', { params });
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getRemoteServer = async (uuid: string): Promise<RemoteServer> => {
|
||||
const { data } = await client.get<RemoteServer>(`/remote-servers/${uuid}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createRemoteServer = async (server: Partial<RemoteServer>): Promise<RemoteServer> => {
|
||||
const { data } = await client.post<RemoteServer>('/remote-servers', server);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateRemoteServer = async (uuid: string, server: Partial<RemoteServer>): Promise<RemoteServer> => {
|
||||
const { data } = await client.put<RemoteServer>(`/remote-servers/${uuid}`, server);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteRemoteServer = async (uuid: string): Promise<void> => {
|
||||
await client.delete(`/remote-servers/${uuid}`);
|
||||
};
|
||||
|
||||
export const testRemoteServerConnection = async (uuid: string): Promise<{ address: string }> => {
|
||||
const { data } = await client.post<{ address: string }>(`/remote-servers/${uuid}/test`);
|
||||
return data;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import client from './client';
|
||||
|
||||
export interface SetupStatus {
|
||||
setupRequired: boolean;
|
||||
}
|
||||
|
||||
export interface SetupRequest {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const getSetupStatus = async (): Promise<SetupStatus> => {
|
||||
const response = await client.get<SetupStatus>('/setup');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const performSetup = async (data: SetupRequest): Promise<void> => {
|
||||
await client.post('/setup', data);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
import { LoadingSpinner } from './LoadingStates'
|
||||
|
||||
export default function CertificateList() {
|
||||
const { certificates, isLoading, error } = useCertificates()
|
||||
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
if (error) return <div className="text-red-500">Failed to load certificates</div>
|
||||
|
||||
return (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm text-gray-400">
|
||||
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
|
||||
<tr>
|
||||
<th className="px-6 py-3">Domain</th>
|
||||
<th className="px-6 py-3">Issuer</th>
|
||||
<th className="px-6 py-3">Expires</th>
|
||||
<th className="px-6 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{certificates.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
|
||||
No certificates found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
certificates.map((cert) => (
|
||||
<tr key={cert.domain} className="hover:bg-gray-800/50 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
|
||||
<td className="px-6 py-4">{cert.issuer}</td>
|
||||
<td className="px-6 py-4">
|
||||
{new Date(cert.expires_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<StatusBadge status={cert.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles = {
|
||||
valid: 'bg-green-900/30 text-green-400 border-green-800',
|
||||
expiring: 'bg-yellow-900/30 text-yellow-400 border-yellow-800',
|
||||
expired: 'bg-red-900/30 text-red-400 border-red-800',
|
||||
}
|
||||
|
||||
const labels = {
|
||||
valid: 'Valid',
|
||||
expiring: 'Expiring Soon',
|
||||
expired: 'Expired',
|
||||
}
|
||||
|
||||
const style = styles[status as keyof typeof styles] || styles.valid
|
||||
const label = labels[status as keyof typeof labels] || status
|
||||
|
||||
return (
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${style}`}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
interface Props {
|
||||
session: { id: string }
|
||||
onReview: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ImportBanner({ session, onReview, onCancel }: Props) {
|
||||
return (
|
||||
<div className="bg-yellow-900/20 border border-yellow-600 text-yellow-300 px-4 py-3 rounded mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">Pending Import Session</div>
|
||||
<div className="text-sm text-yellow-400/80">Session ID: {session.id}</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onReview}
|
||||
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-500 text-black rounded text-sm font-medium"
|
||||
>
|
||||
Review Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1 bg-gray-800 hover:bg-gray-700 text-yellow-300 border border-yellow-700 rounded text-sm font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface HostPreview {
|
||||
domain_names: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
hosts: HostPreview[]
|
||||
conflicts: string[]
|
||||
errors: string[]
|
||||
onCommit: (resolutions: Record<string, string>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ImportReviewTable({ hosts, conflicts, errors, onCommit, onCancel }: Props) {
|
||||
const [resolutions, setResolutions] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {}
|
||||
conflicts.forEach((d: string) => { init[d] = 'keep' })
|
||||
return init
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleCommit = async () => {
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onCommit(resolutions)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to commit import')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-white">Review Imported Hosts</h2>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Committing...' : 'Commit Import'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="m-4 bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors?.length > 0 && (
|
||||
<div className="m-4 bg-yellow-900/20 border border-yellow-600 text-yellow-300 px-4 py-3 rounded">
|
||||
<div className="font-medium mb-2">Issues found during parsing</div>
|
||||
<ul className="list-disc list-inside text-sm">
|
||||
{errors.map((e, i) => (
|
||||
<li key={i}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
</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 Names
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Conflict Resolution
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{hosts.map((h, idx) => {
|
||||
const domain = h.domain_names
|
||||
const hasConflict = conflicts.includes(domain)
|
||||
return (
|
||||
<tr key={`${domain}-${idx}`} className="hover:bg-gray-900/50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-white">{domain}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{hasConflict ? (
|
||||
<select
|
||||
value={resolutions[domain]}
|
||||
onChange={e => setResolutions({ ...resolutions, [domain]: e.target.value })}
|
||||
className="bg-gray-900 border border-gray-700 text-white rounded px-2 py-1"
|
||||
>
|
||||
<option value="keep">Keep Existing</option>
|
||||
<option value="overwrite">Overwrite</option>
|
||||
<option value="skip">Skip</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
|
||||
No conflict
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', path: '/', icon: '📊' },
|
||||
{ name: 'Proxy Hosts', path: '/proxy-hosts', icon: '🌐' },
|
||||
{ name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' },
|
||||
{ name: 'Certificates', path: '/certificates', icon: '🔒' },
|
||||
{ name: 'Import Caddyfile', path: '/import', icon: '📥' },
|
||||
{ name: 'Settings', path: '/settings', icon: '⚙️' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-dark-bg flex transition-colors duration-200">
|
||||
{/* Mobile Header */}
|
||||
<div className="lg:hidden fixed top-0 left-0 right-0 h-16 bg-white dark:bg-dark-sidebar border-b border-gray-200 dark:border-gray-800 flex items-center justify-between px-4 z-40">
|
||||
<h1 className="text-lg font-bold text-gray-900 dark:text-white">CPM+</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="sm" onClick={() => setSidebarOpen(!sidebarOpen)}>
|
||||
{sidebarOpen ? '✕' : '☰'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={`
|
||||
fixed lg:static inset-y-0 left-0 z-30 w-64 transform transition-transform duration-200 ease-in-out
|
||||
bg-white dark:bg-dark-sidebar border-r border-gray-200 dark:border-gray-800 flex flex-col
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
<div className="p-6 hidden lg:flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">CPM+</h1>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 space-y-1 mt-16 lg:mt-0">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-active dark:text-white'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500">
|
||||
Version 0.1.0
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-20 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto pt-16 lg:pt-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
|
||||
interface ProxyHostFormProps {
|
||||
host?: ProxyHost
|
||||
onSubmit: (data: Partial<ProxyHost>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
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 { servers: remoteServers } = useRemoteServers()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
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 htmlFor="quick-select" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Quick Select from Remote Servers
|
||||
</label>
|
||||
<select
|
||||
id="quick-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 htmlFor="advanced-config" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Advanced Caddy Config (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="advanced-config"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RemoteServer } from '../api/remoteServers'
|
||||
import { remoteServersAPI } from '../services/api'
|
||||
|
||||
interface Props {
|
||||
server?: RemoteServer
|
||||
onSubmit: (data: Partial<RemoteServer>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function RemoteServerForm({ server, onSubmit, onCancel }: Props) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: server?.name || '',
|
||||
provider: server?.provider || 'generic',
|
||||
host: server?.host || '',
|
||||
port: server?.port ?? 22,
|
||||
username: server?.username || '',
|
||||
enabled: server?.enabled ?? true,
|
||||
})
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
name: server?.name || '',
|
||||
provider: server?.provider || 'generic',
|
||||
host: server?.host || '',
|
||||
port: server?.port ?? 22,
|
||||
username: server?.username || '',
|
||||
enabled: server?.enabled ?? true,
|
||||
})
|
||||
}, [server])
|
||||
|
||||
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 server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!server?.uuid) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await remoteServersAPI.test(server.uuid)
|
||||
alert(`Connection successful: ${result.address}`)
|
||||
} catch (err) {
|
||||
setError('Connection failed')
|
||||
} finally {
|
||||
setLoading(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-6">
|
||||
{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 className="grid grid-cols-2 gap-4">
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Port</label>
|
||||
<input
|
||||
type="number"
|
||||
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>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={e => setFormData({ ...formData, username: 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>
|
||||
|
||||
<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 className="flex gap-3 justify-end pt-4 border-t border-gray-800">
|
||||
{server && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
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 mr-auto"
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const RequireAuth: React.FC<{ children: JSX.Element }> = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>; // Or a spinner
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default RequireAuth;
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getSetupStatus } from '../api/setup';
|
||||
|
||||
interface SetupGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SetupGuard: React.FC<SetupGuardProps> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: getSetupStatus,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.setupRequired) {
|
||||
navigate('/setup');
|
||||
}
|
||||
}, [status, navigate]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-blue-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status?.setupRequired) {
|
||||
return null; // Will redirect
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useTheme } from '../context/ThemeContext'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="sm" onClick={toggleTheme} title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}>
|
||||
{theme === 'dark' ? '☀️' : '🌙'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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('Review Imported Hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('test.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays conflicts with resolution dropdowns', () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('test.example.com')).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox')).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('Issues found during parsing')).toBeInTheDocument()
|
||||
expect(screen.getByText('Invalid Caddyfile syntax')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing required field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCommit with resolutions', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
const dropdown = screen.getByRole('combobox')
|
||||
fireEvent.change(dropdown, { target: { value: 'overwrite' } })
|
||||
|
||||
const commitButton = screen.getByText('Commit Import')
|
||||
fireEvent.click(commitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCommit).toHaveBeenCalledWith({
|
||||
'test.example.com': 'overwrite',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', () => {
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={[]}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Back'))
|
||||
expect(mockOnCancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows conflict indicator on conflicting hosts', () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No conflict')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Layout from '../Layout'
|
||||
import { ThemeProvider } from '../../context/ThemeContext'
|
||||
|
||||
const renderWithProviders = (children: ReactNode) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Layout', () => {
|
||||
it('renders the application title', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('CPM+')[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all navigation items', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument()
|
||||
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Certificates')).toBeInTheDocument()
|
||||
expect(screen.getByText('Import Caddyfile')).toBeInTheDocument()
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders children content', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div data-testid="test-content">Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('test-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays version information', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Version 0.1.0')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,202 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ProxyHostForm from '../ProxyHostForm'
|
||||
import { mockRemoteServers } from '../../test/mockData'
|
||||
|
||||
// Mock the hook
|
||||
vi.mock('../../hooks/useRemoteServers', () => ({
|
||||
useRemoteServers: vi.fn(() => ({
|
||||
servers: mockRemoteServers,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
createRemoteServer: vi.fn(),
|
||||
updateRemoteServer: vi.fn(),
|
||||
deleteRemoteServer: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ProxyHostForm', () => {
|
||||
const mockOnSubmit = vi.fn(() => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders create form with empty fields', async () => {
|
||||
renderWithClient(
|
||||
<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',
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<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 () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Local Docker Registry/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
renderWithClient(
|
||||
<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 () => {
|
||||
renderWithClient(
|
||||
<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 () => {
|
||||
renderWithClient(
|
||||
<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()
|
||||
})
|
||||
|
||||
it('populates fields when remote server is selected', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Local Docker Registry/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const select = screen.getByRole('combobox', { name: /quick select/i })
|
||||
fireEvent.change(select, { target: { value: mockRemoteServers[0].uuid } })
|
||||
|
||||
expect(screen.getByDisplayValue(mockRemoteServers[0].host)).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue(mockRemoteServers[0].port)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays error message on submission failure', async () => {
|
||||
const mockErrorSubmit = vi.fn(() => Promise.reject(new Error('Submission failed')))
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockErrorSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('example.com, www.example.com'), {
|
||||
target: { value: 'test.com' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.100'), {
|
||||
target: { value: 'localhost' },
|
||||
})
|
||||
fireEvent.change(screen.getByDisplayValue('80'), {
|
||||
target: { value: '8080' },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Create'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Submission failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles advanced config input', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const advancedInput = screen.getByLabelText(/Advanced Caddy Config/i)
|
||||
fireEvent.change(advancedInput, { target: { value: 'header_up X-Test "True"' } })
|
||||
|
||||
expect(advancedInput).toHaveValue('header_up X-Test "True"')
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
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('22')
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
isLoading?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
|
||||
secondary: 'bg-gray-700 text-white hover:bg-gray-600 focus:ring-gray-500',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
ghost: 'text-gray-400 hover:text-white hover:bg-gray-800 focus:ring-gray-500',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
baseStyles,
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-current" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
title?: string
|
||||
description?: string
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
export function Card({ children, className, title, description, footer }: CardProps) {
|
||||
return (
|
||||
<div className={clsx('bg-dark-card rounded-lg border border-gray-800 overflow-hidden', className)}>
|
||||
{(title || description) && (
|
||||
<div className="px-6 py-4 border-b border-gray-800">
|
||||
{title && <h3 className="text-lg font-medium text-white">{title}</h3>}
|
||||
{description && <p className="mt-1 text-sm text-gray-400">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="px-6 py-4 bg-gray-900/50 border-t border-gray-800">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { InputHTMLAttributes, forwardRef } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, helperText, className, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'w-full bg-gray-900 border rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 transition-colors',
|
||||
error
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-gray-700 focus:ring-blue-500 focus:border-blue-500',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
@@ -1,71 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import client from '../api/client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
interface User {
|
||||
user_id: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: () => void;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const response = await client.get('/auth/me');
|
||||
setUser(response.data);
|
||||
} catch (error) {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const login = () => {
|
||||
// Token is stored in cookie by backend, but we might want to store it in memory or trigger a re-fetch
|
||||
// Actually, if backend sets cookie, we just need to fetch /auth/me
|
||||
client.get('/auth/me').then((response: AxiosResponse<User>) => {
|
||||
setUser(response.data);
|
||||
}).catch(() => {
|
||||
setUser(null);
|
||||
});
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await client.post('/auth/logout');
|
||||
} catch (error) {
|
||||
console.error("Logout failed", error);
|
||||
}
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, isAuthenticated: !!user, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('theme')
|
||||
return (saved as Theme) || 'dark'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(theme)
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useImport } from '../useImport'
|
||||
import * as api from '../../api/import'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/import', () => ({
|
||||
uploadCaddyfile: vi.fn(),
|
||||
getImportPreview: vi.fn(),
|
||||
commitImport: vi.fn(),
|
||||
cancelImport: vi.fn(),
|
||||
getImportStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('useImport', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('starts with no active session', async () => {
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.session).toBeNull()
|
||||
})
|
||||
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.uploadCaddyfile).mockResolvedValue({ session: mockSession })
|
||||
vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession })
|
||||
vi.mocked(api.getImportPreview).mockResolvedValue(mockPreview)
|
||||
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('example.com { reverse_proxy localhost:8080 }')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).toEqual(mockSession)
|
||||
})
|
||||
|
||||
expect(api.uploadCaddyfile).toHaveBeenCalledWith('example.com { reverse_proxy localhost:8080 }')
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('handles upload errors', async () => {
|
||||
const mockError = new Error('Upload failed')
|
||||
vi.mocked(api.uploadCaddyfile).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
let threw = false
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.upload('invalid')
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
})
|
||||
expect(threw).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
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',
|
||||
}
|
||||
|
||||
let isCommitted = false
|
||||
vi.mocked(api.uploadCaddyfile).mockResolvedValue({ session: mockSession })
|
||||
vi.mocked(api.getImportStatus).mockImplementation(async () => {
|
||||
if (isCommitted) return { has_pending: false }
|
||||
return { has_pending: true, session: mockSession }
|
||||
})
|
||||
vi.mocked(api.getImportPreview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] })
|
||||
vi.mocked(api.commitImport).mockImplementation(async () => {
|
||||
isCommitted = true
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('test')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).toEqual(mockSession)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.commit({ 'test.com': 'skip' })
|
||||
})
|
||||
|
||||
expect(api.commitImport).toHaveBeenCalledWith({ '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',
|
||||
}
|
||||
|
||||
let isCancelled = false
|
||||
vi.mocked(api.uploadCaddyfile).mockResolvedValue({ session: mockSession })
|
||||
vi.mocked(api.getImportStatus).mockImplementation(async () => {
|
||||
if (isCancelled) return { has_pending: false }
|
||||
return { has_pending: true, session: mockSession }
|
||||
})
|
||||
vi.mocked(api.getImportPreview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] })
|
||||
vi.mocked(api.cancelImport).mockImplementation(async () => {
|
||||
isCancelled = true
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('test')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).toEqual(mockSession)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.cancel()
|
||||
})
|
||||
|
||||
expect(api.cancelImport).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
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.uploadCaddyfile).mockResolvedValue({ session: mockSession })
|
||||
vi.mocked(api.getImportStatus).mockResolvedValue({ has_pending: true, session: mockSession })
|
||||
vi.mocked(api.getImportPreview).mockResolvedValue({ hosts: [], conflicts: [], errors: [] })
|
||||
|
||||
const mockError = new Error('Commit failed')
|
||||
vi.mocked(api.commitImport).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useImport(), { wrapper: createWrapper() })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upload('test')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.session).toEqual(mockSession)
|
||||
})
|
||||
|
||||
let threw = false
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.commit({})
|
||||
} catch {
|
||||
threw = true
|
||||
}
|
||||
})
|
||||
expect(threw).toBe(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Commit failed')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,198 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useProxyHosts } from '../useProxyHosts'
|
||||
import * as api from '../../api/proxyHosts'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
getProxyHosts: vi.fn(),
|
||||
createProxyHost: vi.fn(),
|
||||
updateProxyHost: vi.fn(),
|
||||
deleteProxyHost: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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.getProxyHosts).mockResolvedValue(mockHosts)
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
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.getProxyHosts).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('handles loading errors', async () => {
|
||||
const mockError = new Error('Failed to fetch')
|
||||
vi.mocked(api.getProxyHosts).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
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.getProxyHosts).mockResolvedValue([])
|
||||
const newHost = { domain_names: 'new.com', forward_host: 'localhost', forward_port: 9000 }
|
||||
const createdHost = { uuid: '3', ...newHost, enabled: true }
|
||||
|
||||
vi.mocked(api.createProxyHost).mockImplementation(async () => {
|
||||
vi.mocked(api.getProxyHosts).mockResolvedValue([createdHost])
|
||||
return createdHost
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createHost(newHost)
|
||||
})
|
||||
|
||||
expect(api.createProxyHost).toHaveBeenCalledWith(newHost)
|
||||
await waitFor(() => {
|
||||
expect(result.current.hosts).toContainEqual(createdHost)
|
||||
})
|
||||
})
|
||||
|
||||
it('updates an existing proxy host', async () => {
|
||||
const existingHost = { uuid: '1', domain_names: 'test.com', enabled: true, forward_host: 'localhost', forward_port: 8080 }
|
||||
let hosts = [existingHost]
|
||||
vi.mocked(api.getProxyHosts).mockImplementation(() => Promise.resolve(hosts))
|
||||
|
||||
const updatedHost = { ...existingHost, domain_names: 'updated.com' }
|
||||
vi.mocked(api.updateProxyHost).mockImplementation(async (uuid, data) => {
|
||||
hosts = [{ ...existingHost, ...data }]
|
||||
return hosts[0]
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateHost('1', { domain_names: 'updated.com' })
|
||||
})
|
||||
|
||||
expect(api.updateProxyHost).toHaveBeenCalledWith('1', { domain_names: 'updated.com' })
|
||||
await waitFor(() => {
|
||||
expect(result.current.hosts[0].domain_names).toBe('updated.com')
|
||||
})
|
||||
})
|
||||
|
||||
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.getProxyHosts).mockResolvedValue(hosts)
|
||||
vi.mocked(api.deleteProxyHost).mockImplementation(async (uuid) => {
|
||||
const remaining = hosts.filter(h => h.uuid !== uuid)
|
||||
vi.mocked(api.getProxyHosts).mockResolvedValue(remaining)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteHost('1')
|
||||
})
|
||||
|
||||
expect(api.deleteProxyHost).toHaveBeenCalledWith('1')
|
||||
await waitFor(() => {
|
||||
expect(result.current.hosts).toHaveLength(1)
|
||||
expect(result.current.hosts[0].uuid).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles create errors', async () => {
|
||||
vi.mocked(api.getProxyHosts).mockResolvedValue([])
|
||||
const mockError = new Error('Failed to create')
|
||||
vi.mocked(api.createProxyHost).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
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.getProxyHosts).mockResolvedValue([host])
|
||||
const mockError = new Error('Failed to update')
|
||||
vi.mocked(api.updateProxyHost).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
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.getProxyHosts).mockResolvedValue([host])
|
||||
const mockError = new Error('Failed to delete')
|
||||
vi.mocked(api.deleteProxyHost).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useProxyHosts(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await expect(result.current.deleteHost('1')).rejects.toThrow('Failed to delete')
|
||||
})
|
||||
})
|
||||
@@ -1,230 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useRemoteServers } from '../useRemoteServers'
|
||||
import * as api from '../../api/remoteServers'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/remoteServers', () => ({
|
||||
getRemoteServers: vi.fn(),
|
||||
createRemoteServer: vi.fn(),
|
||||
updateRemoteServer: vi.fn(),
|
||||
deleteRemoteServer: vi.fn(),
|
||||
testRemoteServerConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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.getRemoteServers).mockResolvedValue(mockServers)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
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.getRemoteServers).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('handles loading errors', async () => {
|
||||
const mockError = new Error('Network error')
|
||||
vi.mocked(api.getRemoteServers).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Network error')
|
||||
expect(result.current.servers).toEqual([])
|
||||
})
|
||||
|
||||
it('creates a new remote server', async () => {
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue([])
|
||||
const newServer = { name: 'New Server', host: 'new.local', port: 5000, provider: 'generic' }
|
||||
const createdServer = { uuid: '4', ...newServer, enabled: true }
|
||||
|
||||
vi.mocked(api.createRemoteServer).mockImplementation(async () => {
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue([createdServer])
|
||||
return createdServer
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createServer(newServer)
|
||||
})
|
||||
|
||||
expect(api.createRemoteServer).toHaveBeenCalledWith(newServer)
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers).toContainEqual(createdServer)
|
||||
})
|
||||
})
|
||||
|
||||
it('updates an existing remote server', async () => {
|
||||
const existingServer = { uuid: '1', name: 'Server 1', host: 'localhost', port: 8080, enabled: true }
|
||||
let servers = [existingServer]
|
||||
vi.mocked(api.getRemoteServers).mockImplementation(() => Promise.resolve(servers))
|
||||
|
||||
const updatedServer = { ...existingServer, name: 'Updated Server' }
|
||||
vi.mocked(api.updateRemoteServer).mockImplementation(async (uuid, data) => {
|
||||
servers = [{ ...existingServer, ...data }]
|
||||
return servers[0]
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.updateServer('1', { name: 'Updated Server' })
|
||||
})
|
||||
|
||||
expect(api.updateRemoteServer).toHaveBeenCalledWith('1', { name: 'Updated Server' })
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers[0].name).toBe('Updated Server')
|
||||
})
|
||||
})
|
||||
|
||||
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.getRemoteServers).mockResolvedValue(servers)
|
||||
vi.mocked(api.deleteRemoteServer).mockImplementation(async (uuid) => {
|
||||
const remaining = servers.filter(s => s.uuid !== uuid)
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue(remaining)
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteServer('1')
|
||||
})
|
||||
|
||||
expect(api.deleteRemoteServer).toHaveBeenCalledWith('1')
|
||||
await waitFor(() => {
|
||||
expect(result.current.servers).toHaveLength(1)
|
||||
expect(result.current.servers[0].uuid).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
it('tests server connection', async () => {
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue([])
|
||||
const testResult = { reachable: true, address: 'localhost:8080' }
|
||||
vi.mocked(api.testRemoteServerConnection).mockResolvedValue(testResult)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
const response = await result.current.testConnection('1')
|
||||
|
||||
expect(api.testRemoteServerConnection).toHaveBeenCalledWith('1')
|
||||
expect(response).toEqual(testResult)
|
||||
})
|
||||
|
||||
it('handles create errors', async () => {
|
||||
vi.mocked(api.getRemoteServers).mockResolvedValue([])
|
||||
const mockError = new Error('Failed to create')
|
||||
vi.mocked(api.createRemoteServer).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
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.getRemoteServers).mockResolvedValue([server])
|
||||
const mockError = new Error('Failed to update')
|
||||
vi.mocked(api.updateRemoteServer).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
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.getRemoteServers).mockResolvedValue([server])
|
||||
const mockError = new Error('Failed to delete')
|
||||
vi.mocked(api.deleteRemoteServer).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
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.getRemoteServers).mockResolvedValue([])
|
||||
const mockError = new Error('Connection failed')
|
||||
vi.mocked(api.testRemoteServerConnection).mockRejectedValue(mockError)
|
||||
|
||||
const { result } = renderHook(() => useRemoteServers(), { wrapper: createWrapper() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await expect(result.current.testConnection('1')).rejects.toThrow('Connection failed')
|
||||
})
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getCertificates } from '../api/certificates'
|
||||
|
||||
export function useCertificates() {
|
||||
const { data: certificates = [], isLoading, error } = useQuery({
|
||||
queryKey: ['certificates'],
|
||||
queryFn: getCertificates,
|
||||
})
|
||||
|
||||
return {
|
||||
certificates,
|
||||
isLoading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
uploadCaddyfile,
|
||||
getImportPreview,
|
||||
commitImport,
|
||||
cancelImport,
|
||||
getImportStatus,
|
||||
ImportSession,
|
||||
ImportPreview
|
||||
} from '../api/import';
|
||||
|
||||
export const QUERY_KEY = ['import-session'];
|
||||
|
||||
export function useImport() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Poll for status if we think there's an active session
|
||||
const statusQuery = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: getImportStatus,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
// Poll if we have a pending session in reviewing state
|
||||
if (data?.has_pending && data?.session?.state === 'reviewing') {
|
||||
return 3000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const previewQuery = useQuery({
|
||||
queryKey: ['import-preview'],
|
||||
queryFn: getImportPreview,
|
||||
enabled: !!statusQuery.data?.has_pending && statusQuery.data?.session?.state === 'reviewing',
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (content: string) => uploadCaddyfile(content),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: ['import-preview'] });
|
||||
},
|
||||
});
|
||||
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: (resolutions: Record<string, string>) => commitImport(resolutions),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: ['import-preview'] });
|
||||
// Also invalidate proxy hosts as they might have changed
|
||||
queryClient.invalidateQueries({ queryKey: ['proxy-hosts'] });
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => cancelImport(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: ['import-preview'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
session: statusQuery.data?.session || null,
|
||||
preview: previewQuery.data || null,
|
||||
loading: statusQuery.isLoading || uploadMutation.isPending || commitMutation.isPending || cancelMutation.isPending,
|
||||
error: (statusQuery.error || previewQuery.error || uploadMutation.error || commitMutation.error || cancelMutation.error)
|
||||
? ((statusQuery.error || previewQuery.error || uploadMutation.error || commitMutation.error || cancelMutation.error) as Error).message
|
||||
: null,
|
||||
upload: uploadMutation.mutateAsync,
|
||||
commit: commitMutation.mutateAsync,
|
||||
cancel: cancelMutation.mutateAsync,
|
||||
};
|
||||
}
|
||||
|
||||
export type { ImportSession, ImportPreview };
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getProxyHosts,
|
||||
createProxyHost,
|
||||
updateProxyHost,
|
||||
deleteProxyHost,
|
||||
ProxyHost
|
||||
} from '../api/proxyHosts';
|
||||
|
||||
export const QUERY_KEY = ['proxy-hosts'];
|
||||
|
||||
export function useProxyHosts() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: getProxyHosts,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (host: Partial<ProxyHost>) => createProxyHost(host),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ uuid, data }: { uuid: string; data: Partial<ProxyHost> }) =>
|
||||
updateProxyHost(uuid, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (uuid: string) => deleteProxyHost(uuid),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
hosts: query.data || [],
|
||||
loading: query.isLoading,
|
||||
error: query.error ? (query.error as Error).message : null,
|
||||
createHost: createMutation.mutateAsync,
|
||||
updateHost: (uuid: string, data: Partial<ProxyHost>) => updateMutation.mutateAsync({ uuid, data }),
|
||||
deleteHost: deleteMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export type { ProxyHost };
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
getRemoteServers,
|
||||
createRemoteServer,
|
||||
updateRemoteServer,
|
||||
deleteRemoteServer,
|
||||
testRemoteServerConnection,
|
||||
RemoteServer
|
||||
} from '../api/remoteServers';
|
||||
|
||||
export const QUERY_KEY = ['remote-servers'];
|
||||
|
||||
export function useRemoteServers(enabledOnly = false) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...QUERY_KEY, { enabled: enabledOnly }],
|
||||
queryFn: () => getRemoteServers(enabledOnly),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (server: Partial<RemoteServer>) => createRemoteServer(server),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ uuid, data }: { uuid: string; data: Partial<RemoteServer> }) =>
|
||||
updateRemoteServer(uuid, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (uuid: string) => deleteRemoteServer(uuid),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const testConnectionMutation = useMutation({
|
||||
mutationFn: (uuid: string) => testRemoteServerConnection(uuid),
|
||||
});
|
||||
|
||||
return {
|
||||
servers: query.data || [],
|
||||
loading: query.isLoading,
|
||||
error: query.error ? (query.error as Error).message : null,
|
||||
createServer: createMutation.mutateAsync,
|
||||
updateServer: (uuid: string, data: Partial<RemoteServer>) => updateMutation.mutateAsync({ uuid, data }),
|
||||
deleteServer: deleteMutation.mutateAsync,
|
||||
testConnection: testConnectionMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isTestingConnection: testConnectionMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export type { RemoteServer };
|
||||
@@ -1,45 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App.tsx'
|
||||
import { ThemeProvider } from './context/ThemeContext'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
import CertificateList from '../components/CertificateList'
|
||||
|
||||
export default function Certificates() {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Certificates</h1>
|
||||
<p className="text-gray-400">
|
||||
View and manage SSL certificates automatically acquired by Caddy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CertificateList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import { checkHealth } from '../api/health'
|
||||
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 fetchHealth = async () => {
|
||||
try {
|
||||
const result = await checkHealth()
|
||||
setHealth(result)
|
||||
} catch (err) {
|
||||
setHealth({ status: 'error' })
|
||||
}
|
||||
}
|
||||
fetchHealth()
|
||||
}, [])
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import client from '../api/client';
|
||||
|
||||
interface HealthResponse {
|
||||
status: string;
|
||||
service: string;
|
||||
}
|
||||
|
||||
const fetchHealth = async (): Promise<HealthResponse> => {
|
||||
const { data } = await client.get<HealthResponse>('/health');
|
||||
return data;
|
||||
};
|
||||
|
||||
const HealthStatus = () => {
|
||||
const { data, isLoading, isError } = useQuery({ queryKey: ['health'], queryFn: fetchHealth });
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>System Status</h2>
|
||||
{isLoading && <p>Checking health…</p>}
|
||||
{isError && <p className="error">Unable to reach backend</p>}
|
||||
{data && (
|
||||
<ul>
|
||||
<li>Service: {data.service}</li>
|
||||
<li>Status: {data.status}</li>
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HealthStatus;
|
||||
@@ -1,145 +0,0 @@
|
||||
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 && preview.preview && (
|
||||
<ImportReviewTable
|
||||
hosts={preview.preview.hosts}
|
||||
conflicts={Object.keys(preview.preview.conflicts)}
|
||||
errors={preview.preview.errors}
|
||||
onCommit={handleCommit}
|
||||
onCancel={() => setShowReview(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { toast } from '../components/Toast'
|
||||
import client from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await client.post('/auth/login', { email, password })
|
||||
login()
|
||||
toast.success('Logged in successfully')
|
||||
navigate('/')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.error || 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md" title="Login">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<Button type="submit" className="w-full" isLoading={loading}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import type { RemoteServer } from '../api/remoteServers'
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { toast } from '../components/Toast'
|
||||
import client from '../api/client'
|
||||
|
||||
export default function Settings() {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleChangePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error('New passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await client.post('/auth/change-password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
toast.success('Password updated successfully')
|
||||
setOldPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.error || 'Failed to update password')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">Settings</h1>
|
||||
<div className="grid gap-6">
|
||||
<Card title="Change Password" className="max-w-md">
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<Input
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={e => setOldPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<Input
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<Button type="submit" isLoading={loading}>
|
||||
Update Password
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { getSetupStatus, performSetup, SetupRequest } from '../api/setup';
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState<SetupRequest>({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['setupStatus'],
|
||||
queryFn: getSetupStatus,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (status && !status.setupRequired) {
|
||||
navigate('/login');
|
||||
}
|
||||
}, [status, navigate]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: performSetup,
|
||||
onSuccess: () => {
|
||||
navigate('/login');
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.error || 'Setup failed');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate(formData);
|
||||
};
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-blue-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status && !status.setupRequired) {
|
||||
return null; // Will redirect in useEffect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
Welcome to CPM+
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Create your administrator account to get started.
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div className="mb-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Admin User"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="admin@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
This email will be used for Let's Encrypt certificate notifications and recovery.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="********"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={mutation.isPending}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
'Loading...'
|
||||
) : (
|
||||
'Create Admin Account'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Setup;
|
||||
@@ -1,127 +0,0 @@
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import Setup from '../Setup';
|
||||
import * as setupApi from '../../api/setup';
|
||||
|
||||
// Mock react-router-dom
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/setup', () => ({
|
||||
getSetupStatus: vi.fn(),
|
||||
performSetup: vi.fn(),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Setup Page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it('renders setup form when setup is required', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to CPM+')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Name')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Email Address')).toBeTruthy();
|
||||
expect(screen.getByLabelText('Password')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not render form when setup is not required', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false });
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Welcome to CPM+')).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('submits form successfully', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
vi.mocked(setupApi.performSetup).mockResolvedValue();
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to CPM+')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Admin' } });
|
||||
fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'admin@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Admin Account' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setupApi.performSetup).toHaveBeenCalledWith({
|
||||
name: 'Admin',
|
||||
email: 'admin@example.com',
|
||||
password: 'password123',
|
||||
}, expect.anything());
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error on submission failure', async () => {
|
||||
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
|
||||
vi.mocked(setupApi.performSetup).mockRejectedValue({
|
||||
response: { data: { error: 'Setup failed' } }
|
||||
});
|
||||
|
||||
renderWithProviders(<Setup />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Welcome to CPM+')).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Admin' } });
|
||||
fireEvent.change(screen.getByLabelText('Email Address'), { target: { value: 'admin@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create Admin Account' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Setup failed')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
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'),
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
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: [],
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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: () => {},
|
||||
}),
|
||||
})
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user