chore: remove cached

This commit is contained in:
Wikid82
2025-11-24 18:21:11 +00:00
parent 5b041819bb
commit 9c842e7eab
394 changed files with 0 additions and 44139 deletions
-465
View File
@@ -1,465 +0,0 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card } from '../components/ui/Card'
import { Input } from '../components/ui/Input'
import { Button } from '../components/ui/Button'
import { toast } from '../utils/toast'
import { getProfile, regenerateApiKey, updateProfile } from '../api/user'
import { getSettings, updateSetting } from '../api/settings'
import { Copy, RefreshCw, Shield, Mail, User, AlertTriangle } from 'lucide-react'
import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter'
import { isValidEmail } from '../utils/validation'
import { useAuth } from '../hooks/useAuth'
export default function Account() {
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
// Profile State
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [emailValid, setEmailValid] = useState<boolean | null>(null)
const [confirmPasswordForUpdate, setConfirmPasswordForUpdate] = useState('')
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false)
const [pendingProfileUpdate, setPendingProfileUpdate] = useState<{name: string, email: string} | null>(null)
const [previousEmail, setPreviousEmail] = useState('')
const [showEmailConfirmModal, setShowEmailConfirmModal] = useState(false)
// Certificate Email State
const [certEmail, setCertEmail] = useState('')
const [certEmailValid, setCertEmailValid] = useState<boolean | null>(null)
const [useUserEmail, setUseUserEmail] = useState(true)
const queryClient = useQueryClient()
const { changePassword } = useAuth()
const { data: profile, isLoading: isLoadingProfile } = useQuery({
queryKey: ['profile'],
queryFn: getProfile,
})
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: getSettings,
})
// Initialize profile state
useEffect(() => {
if (profile) {
setName(profile.name)
setEmail(profile.email)
}
}, [profile])
// Validate profile email
useEffect(() => {
if (email) {
setEmailValid(isValidEmail(email))
} else {
setEmailValid(null)
}
}, [email])
// Initialize cert email state
useEffect(() => {
if (settings && profile) {
const savedEmail = settings['caddy.email']
if (savedEmail && savedEmail !== profile.email) {
setCertEmail(savedEmail)
setUseUserEmail(false)
} else {
setCertEmail(profile.email)
setUseUserEmail(true)
}
}
}, [settings, profile])
// Validate cert email
useEffect(() => {
if (certEmail && !useUserEmail) {
setCertEmailValid(isValidEmail(certEmail))
} else {
setCertEmailValid(null)
}
}, [certEmail, useUserEmail])
const updateProfileMutation = useMutation({
mutationFn: updateProfile,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] })
toast.success('Profile updated successfully')
},
onError: (error: any) => {
toast.error(`Failed to update profile: ${error.message}`)
},
})
const updateSettingMutation = useMutation({
mutationFn: (variables: { key: string; value: string; category: string }) =>
updateSetting(variables.key, variables.value, variables.category),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] })
toast.success('Certificate email updated')
},
onError: (error: any) => {
toast.error(`Failed to update certificate email: ${error.message}`)
},
})
const regenerateMutation = useMutation({
mutationFn: regenerateApiKey,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] })
toast.success('API Key regenerated successfully')
},
onError: (error: any) => {
toast.error(`Failed to regenerate API key: ${error.message}`)
},
})
const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault()
if (!emailValid) return
// Check if email changed
if (email !== profile?.email) {
setPreviousEmail(profile?.email || '')
setPendingProfileUpdate({ name, email })
setShowPasswordPrompt(true)
return
}
updateProfileMutation.mutate({ name, email })
}
const handlePasswordPromptSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!pendingProfileUpdate) return
setShowPasswordPrompt(false)
// If email changed, we might need to ask about cert email too
// But first, let's update the profile with the password
updateProfileMutation.mutate({
name: pendingProfileUpdate.name,
email: pendingProfileUpdate.email,
current_password: confirmPasswordForUpdate
}, {
onSuccess: () => {
setConfirmPasswordForUpdate('')
// Check if we need to prompt for cert email
// We do this AFTER success to ensure profile is updated
// But wait, if we do it after success, the profile email is already new.
// The user wanted to be asked.
// Let's ask about cert email first? No, user said "Updateing email test the popup worked as expected"
// But "I chose to keep my certificate email as the old email and it changed anyway"
// This implies the logic below is flawed or the backend/frontend sync is weird.
// Let's show the cert email modal if the update was successful AND it was an email change
setShowEmailConfirmModal(true)
},
onError: () => {
setConfirmPasswordForUpdate('')
}
})
}
const confirmEmailUpdate = (updateCertEmail: boolean) => {
setShowEmailConfirmModal(false)
if (updateCertEmail) {
updateSettingMutation.mutate({
key: 'caddy.email',
value: email,
category: 'caddy'
})
setCertEmail(email)
setUseUserEmail(true)
} else {
// If user chose NO, we must ensure the cert email stays as the OLD email.
// If settings['caddy.email'] is empty, it defaults to profile email (which is now NEW).
// So we must explicitly save the OLD email.
const savedEmail = settings?.['caddy.email']
if (!savedEmail && previousEmail) {
updateSettingMutation.mutate({
key: 'caddy.email',
value: previousEmail,
category: 'caddy'
})
// Update local state immediately
setCertEmail(previousEmail)
setUseUserEmail(false)
}
}
}
const handleUpdateCertEmail = (e: React.FormEvent) => {
e.preventDefault()
if (!useUserEmail && !certEmailValid) return
const emailToSave = useUserEmail ? profile?.email : certEmail
if (!emailToSave) return
updateSettingMutation.mutate({
key: 'caddy.email',
value: emailToSave,
category: 'caddy'
})
}
const handlePasswordChange = async (e: React.FormEvent) => {
e.preventDefault()
if (newPassword !== confirmPassword) {
toast.error('New passwords do not match')
return
}
setLoading(true)
try {
await changePassword(oldPassword, newPassword)
toast.success('Password updated successfully')
setOldPassword('')
setNewPassword('')
setConfirmPassword('')
} catch (error: any) {
toast.error(error.message || 'Failed to update password')
} finally {
setLoading(false)
}
}
const copyApiKey = () => {
if (profile?.api_key) {
navigator.clipboard.writeText(profile.api_key)
toast.success('API Key copied to clipboard')
}
}
if (isLoadingProfile) {
return <div className="p-4">Loading profile...</div>
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Account Settings</h1>
{/* Profile Settings */}
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<User className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Profile</h2>
</div>
<form onSubmit={handleUpdateProfile} className="space-y-4">
<Input
label="Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<Input
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
error={emailValid === false ? 'Please enter a valid email address' : undefined}
className={emailValid === true ? 'border-green-500 focus:ring-green-500' : ''}
/>
<div className="flex justify-end">
<Button type="submit" isLoading={updateProfileMutation.isPending} disabled={emailValid === false}>
Save Profile
</Button>
</div>
</form>
</Card>
{/* Certificate Email Settings */}
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<Mail className="w-5 h-5 text-purple-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Certificate Email</h2>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
This email is used for Let's Encrypt notifications and recovery.
</p>
<form onSubmit={handleUpdateCertEmail} className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<input
type="checkbox"
id="useUserEmail"
checked={useUserEmail}
onChange={(e) => {
setUseUserEmail(e.target.checked)
if (e.target.checked && profile) {
setCertEmail(profile.email)
}
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="useUserEmail" className="text-sm text-gray-700 dark:text-gray-300">
Use my account email ({profile?.email})
</label>
</div>
{!useUserEmail && (
<Input
label="Custom Email"
type="email"
value={certEmail}
onChange={(e) => setCertEmail(e.target.value)}
required={!useUserEmail}
error={certEmailValid === false ? 'Please enter a valid email address' : undefined}
className={certEmailValid === true ? 'border-green-500 focus:ring-green-500' : ''}
/>
)}
<div className="flex justify-end">
<Button type="submit" isLoading={updateSettingMutation.isPending} disabled={!useUserEmail && certEmailValid === false}>
Save Certificate Email
</Button>
</div>
</form>
</Card>
{/* Password Change */}
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<Shield className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Change Password</h2>
</div>
<form onSubmit={handlePasswordChange} className="space-y-4">
<Input
label="Current Password"
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
/>
<div>
<Input
label="New Password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
<PasswordStrengthMeter password={newPassword} />
</div>
<Input
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
error={confirmPassword && newPassword !== confirmPassword ? 'Passwords do not match' : undefined}
/>
<div className="flex justify-end">
<Button type="submit" isLoading={loading}>
Update Password
</Button>
</div>
</form>
</Card>
{/* API Key */}
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<div className="p-1 bg-yellow-100 dark:bg-yellow-900/30 rounded">
<span className="text-lg">🔑</span>
</div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">API Key</h2>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
Use this key to authenticate with the API programmatically. Keep it secret!
</p>
<div className="flex gap-2">
<Input
value={profile?.api_key || ''}
readOnly
className="font-mono text-sm bg-gray-50 dark:bg-gray-900"
/>
<Button type="button" variant="secondary" onClick={copyApiKey} title="Copy to clipboard">
<Copy className="w-4 h-4" />
</Button>
<Button
type="button"
variant="secondary"
onClick={() => regenerateMutation.mutate()}
isLoading={regenerateMutation.isPending}
title="Regenerate API Key"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
{/* Password Prompt Modal */}
{showPasswordPrompt && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4 text-blue-600 dark:text-blue-500">
<Shield className="w-6 h-6" />
<h3 className="text-lg font-bold">Confirm Password</h3>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-6">
Please enter your current password to confirm these changes.
</p>
<form onSubmit={handlePasswordPromptSubmit} className="space-y-4">
<Input
type="password"
placeholder="Current Password"
value={confirmPasswordForUpdate}
onChange={(e) => setConfirmPasswordForUpdate(e.target.value)}
required
autoFocus
/>
<div className="flex flex-col gap-3">
<Button type="submit" className="w-full" isLoading={updateProfileMutation.isPending}>
Confirm & Update
</Button>
<Button type="button" onClick={() => {
setShowPasswordPrompt(false)
setConfirmPasswordForUpdate('')
setPendingProfileUpdate(null)
}} variant="ghost" className="w-full text-gray-500">
Cancel
</Button>
</div>
</form>
</div>
</div>
)}
{/* Email Update Confirmation Modal */}
{showEmailConfirmModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<div className="flex items-center gap-3 mb-4 text-yellow-600 dark:text-yellow-500">
<AlertTriangle className="w-6 h-6" />
<h3 className="text-lg font-bold">Update Certificate Email?</h3>
</div>
<p className="text-gray-600 dark:text-gray-300 mb-6">
You are changing your account email to <strong>{email}</strong>.
Do you want to use this new email for SSL certificates as well?
</p>
<div className="flex flex-col gap-3">
<Button onClick={() => confirmEmailUpdate(true)} className="w-full">
Yes, update certificate email too
</Button>
<Button onClick={() => confirmEmailUpdate(false)} variant="secondary" className="w-full">
No, keep using {previousEmail || certEmail}
</Button>
<Button onClick={() => setShowEmailConfirmModal(false)} variant="ghost" className="w-full text-gray-500">
Cancel
</Button>
</div>
</div>
</div>
)}
</div>
)
}
-220
View File
@@ -1,220 +0,0 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { toast } from '../utils/toast'
import { getBackups, createBackup, restoreBackup, deleteBackup, BackupFile } from '../api/backups'
import { getSettings, updateSetting } from '../api/settings'
import { Loader2, Download, RotateCcw, Plus, Archive, Trash2, Save } from 'lucide-react'
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
}
export default function Backups() {
const queryClient = useQueryClient()
const [interval, setInterval] = useState('7')
const [retention, setRetention] = useState('30')
// Fetch Backups
const { data: backups, isLoading: isLoadingBackups } = useQuery({
queryKey: ['backups'],
queryFn: getBackups,
})
// Fetch Settings
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: getSettings,
})
// Update local state when settings load
useState(() => {
if (settings) {
if (settings['backup.interval']) setInterval(settings['backup.interval'])
if (settings['backup.retention']) setRetention(settings['backup.retention'])
}
})
const createMutation = useMutation({
mutationFn: createBackup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] })
toast.success('Backup created successfully')
},
onError: (error: Error) => {
toast.error(`Failed to create backup: ${error.message}`)
},
})
const restoreMutation = useMutation({
mutationFn: restoreBackup,
onSuccess: () => {
toast.success('Backup restored successfully. Please restart the container.')
},
onError: (error: Error) => {
toast.error(`Failed to restore backup: ${error.message}`)
},
})
const deleteMutation = useMutation({
mutationFn: deleteBackup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backups'] })
toast.success('Backup deleted successfully')
},
onError: (error: Error) => {
toast.error(`Failed to delete backup: ${error.message}`)
},
})
const saveSettingsMutation = useMutation({
mutationFn: async () => {
await updateSetting('backup.interval', interval, 'system', 'int')
await updateSetting('backup.retention', retention, 'system', 'int')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] })
toast.success('Backup settings saved')
},
onError: (error: Error) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
const handleDownload = (filename: string) => {
// Trigger download via browser navigation
// The browser will send the auth cookie automatically
window.location.href = `/api/v1/backups/${filename}/download`
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Archive className="w-8 h-8" />
Backups
</h1>
{/* Settings Section */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Configuration</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<Input
label="Backup Interval (Days)"
type="number"
value={interval}
onChange={(e) => setInterval(e.target.value)}
min="1"
/>
<Input
label="Retention Period (Days)"
type="number"
value={retention}
onChange={(e) => setRetention(e.target.value)}
min="1"
/>
<Button
onClick={() => saveSettingsMutation.mutate()}
isLoading={saveSettingsMutation.isPending}
className="mb-0.5"
>
<Save className="w-4 h-4 mr-2" />
Save Settings
</Button>
</div>
</Card>
{/* Actions */}
<div className="flex justify-end">
<Button onClick={() => createMutation.mutate()} isLoading={createMutation.isPending}>
<Plus className="w-4 h-4 mr-2" />
Create Backup
</Button>
</div>
{/* List */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
<tr>
<th className="px-6 py-3 font-medium">Filename</th>
<th className="px-6 py-3 font-medium">Size</th>
<th className="px-6 py-3 font-medium">Created At</th>
<th className="px-6 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{isLoadingBackups ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-blue-500" />
</td>
</tr>
) : backups?.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
No backups found
</td>
</tr>
) : (
backups?.map((backup: BackupFile) => (
<tr key={backup.filename} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td className="px-6 py-4 font-medium text-gray-900 dark:text-white">
{backup.filename}
</td>
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
{formatSize(backup.size)}
</td>
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
{new Date(backup.time).toLocaleString()}
</td>
<td className="px-6 py-4 text-right space-x-2">
<Button
variant="secondary"
size="sm"
onClick={() => handleDownload(backup.filename)}
title="Download"
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
if (confirm('Are you sure you want to restore this backup? Current data will be overwritten.')) {
restoreMutation.mutate(backup.filename)
}
}}
isLoading={restoreMutation.isPending}
title="Restore"
>
<RotateCcw className="w-4 h-4" />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => {
if (confirm('Are you sure you want to delete this backup?')) {
deleteMutation.mutate(backup.filename)
}
}}
isLoading={deleteMutation.isPending}
title="Delete"
>
<Trash2 className="w-4 h-4" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
)
}
-112
View File
@@ -1,112 +0,0 @@
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, X } from 'lucide-react'
import CertificateList from '../components/CertificateList'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { uploadCertificate } from '../api/certificates'
import { toast } from '../utils/toast'
export default function Certificates() {
const [isModalOpen, setIsModalOpen] = useState(false)
const [name, setName] = useState('')
const [certFile, setCertFile] = useState<File | null>(null)
const [keyFile, setKeyFile] = useState<File | null>(null)
const queryClient = useQueryClient()
const uploadMutation = useMutation({
mutationFn: async () => {
if (!certFile || !keyFile) throw new Error('Files required')
await uploadCertificate(name, certFile, keyFile)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] })
setIsModalOpen(false)
setName('')
setCertFile(null)
setKeyFile(null)
toast.success('Certificate uploaded successfully')
},
onError: (error: any) => {
toast.error(`Failed to upload certificate: ${error.message}`)
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
uploadMutation.mutate()
}
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.
</p>
</div>
<Button onClick={() => setIsModalOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Add Certificate
</Button>
</div>
<CertificateList />
{isModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white">Upload Certificate</h2>
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Friendly Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. My Custom Cert"
required
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Certificate (PEM)
</label>
<input
type="file"
accept=".pem,.crt,.cer"
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Private Key (PEM)
</label>
<input
type="file"
accept=".pem,.key"
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-700"
required
/>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>
Cancel
</Button>
<Button type="submit" isLoading={uploadMutation.isPending}>
Upload
</Button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
-98
View File
@@ -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 {
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>
)
}
-105
View File
@@ -1,105 +0,0 @@
import { useState } from 'react'
import { useDomains } from '../hooks/useDomains'
import { Trash2, Plus, Globe, Loader2 } from 'lucide-react'
export default function Domains() {
const { domains, isLoading, isFetching, error, createDomain, deleteDomain } = useDomains()
const [newDomain, setNewDomain] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
if (!newDomain.trim()) return
setIsSubmitting(true)
try {
await createDomain(newDomain)
setNewDomain('')
} catch (err) {
alert('Failed to create domain')
} finally {
setIsSubmitting(false)
}
}
const handleDelete = async (uuid: string) => {
if (confirm('Are you sure you want to delete this domain?')) {
try {
await deleteDomain(uuid)
} catch (err) {
alert('Failed to delete domain')
}
}
}
if (isLoading) return <div className="p-8 text-white">Loading...</div>
if (error) return <div className="p-8 text-red-400">Error loading domains</div>
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-white">Domains</h1>
{isFetching && !isLoading && <Loader2 className="animate-spin text-blue-400" size={24} />}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Add New Domain Card */}
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
<h3 className="text-lg font-medium text-white mb-4 flex items-center gap-2">
<Plus size={20} />
Add Domain
</h3>
<form onSubmit={handleAdd} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">
Domain Name
</label>
<input
type="text"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
placeholder="example.com"
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
disabled={isSubmitting || !newDomain.trim()}
className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 font-medium transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Adding...' : 'Add Domain'}
</button>
</form>
</div>
{/* Domain List */}
{domains.map((domain) => (
<div key={domain.uuid} className="bg-dark-card border border-gray-800 rounded-lg p-6 flex flex-col justify-between">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-900/30 rounded-lg text-blue-400">
<Globe size={24} />
</div>
<div>
<h3 className="text-lg font-medium text-white">{domain.name}</h3>
<p className="text-sm text-gray-500">
Added {new Date(domain.created_at).toLocaleDateString()}
</p>
</div>
</div>
<button
onClick={() => handleDelete(domain.uuid)}
className="text-gray-500 hover:text-red-400 transition-colors"
title="Delete Domain"
>
<Trash2 size={20} />
</button>
</div>
</div>
))}
</div>
</div>
)
}
-32
View File
@@ -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;
-157
View File
@@ -1,157 +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 {
// 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 {
// 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 {
// 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>
)}
{/* Show warning if preview is empty but session exists (e.g. mounted file was empty or invalid) */}
{session && preview && preview.preview && preview.preview.hosts.length === 0 && (
<div className="bg-yellow-900/20 border border-yellow-500 text-yellow-400 px-4 py-3 rounded mb-6">
<p className="font-bold">No domains found in Caddyfile</p>
<p className="text-sm mt-1">
The imported file appears to be empty or contains no valid reverse_proxy directives.
Please check the file content below.
</p>
</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={preview.preview.conflicts}
errors={preview.preview.errors}
caddyfileContent={preview.caddyfile_content}
onCommit={handleCommit}
onCancel={() => setShowReview(false)}
/>
)}
</div>
)
}
-107
View File
@@ -1,107 +0,0 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Card } from '../components/ui/Card'
import { Input } from '../components/ui/Input'
import { Button } from '../components/ui/Button'
import { toast } from '../utils/toast'
import client from '../api/client'
import { useAuth } from '../hooks/useAuth'
import { getSetupStatus } from '../api/setup'
export default function Login() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [showResetInfo, setShowResetInfo] = useState(false)
const { login } = useAuth()
const { data: setupStatus, isLoading: isCheckingSetup } = useQuery({
queryKey: ['setupStatus'],
queryFn: getSetupStatus,
retry: false,
})
useEffect(() => {
if (setupStatus?.setupRequired) {
navigate('/setup')
}
}, [setupStatus, navigate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
await client.post('/auth/login', { email, password })
await login()
await queryClient.invalidateQueries({ queryKey: ['setupStatus'] })
toast.success('Logged in successfully')
navigate('/')
} catch (err: any) {
toast.error(err.response?.data?.error || 'Login failed')
} finally {
setLoading(false)
}
}
if (isCheckingSetup) {
return (
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
<div className="text-white">Checking setup status...</div>
</div>
)
}
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"
/>
<div className="space-y-1">
<Input
label="Password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
placeholder="••••••••"
/>
<div className="flex justify-end">
<button
type="button"
onClick={() => setShowResetInfo(!showResetInfo)}
className="text-sm text-blue-400 hover:text-blue-300"
>
Forgot Password?
</button>
</div>
</div>
{showResetInfo && (
<div className="bg-blue-900/20 border border-blue-800 rounded-lg p-4 text-sm text-blue-200">
<p className="mb-2 font-medium">To reset your password:</p>
<p className="mb-2">Run this command on your server:</p>
<code className="block bg-black/50 p-2 rounded font-mono text-xs break-all select-all">
docker exec -it caddy-proxy-manager /app/backend reset-password &lt;email&gt; &lt;new-password&gt;
</code>
</div>
)}
<Button type="submit" className="w-full" isLoading={loading}>
Sign In
</Button>
</form>
</Card>
</div>
)
}
-186
View File
@@ -1,186 +0,0 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getLogs, getLogContent, downloadLog, LogFilter } from '../api/logs';
import { Card } from '../components/ui/Card';
import { Loader2, FileText, ChevronLeft, ChevronRight } from 'lucide-react';
import { LogTable } from '../components/LogTable';
import { LogFilters } from '../components/LogFilters';
import { Button } from '../components/ui/Button';
const Logs: React.FC = () => {
const [selectedLog, setSelectedLog] = useState<string | null>(null);
// Filter State
const [search, setSearch] = useState('');
const [host, setHost] = useState('');
const [status, setStatus] = useState('');
const [level, setLevel] = useState('');
const [sort, setSort] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(0);
const limit = 50;
const { data: logs, isLoading: isLoadingLogs } = useQuery({
queryKey: ['logs'],
queryFn: getLogs,
});
// Select first log by default if none selected
React.useEffect(() => {
if (!selectedLog && logs && logs.length > 0) {
setSelectedLog(logs[0].name);
}
}, [logs, selectedLog]);
const filter: LogFilter = {
search,
host,
status,
level,
limit,
offset: page * limit,
sort
};
const { data: logData, isLoading: isLoadingContent, refetch: refetchContent } = useQuery({
queryKey: ['logContent', selectedLog, search, host, status, level, page, sort],
queryFn: () => selectedLog ? getLogContent(selectedLog, filter) : Promise.resolve(null),
enabled: !!selectedLog,
});
const handleDownload = () => {
if (selectedLog) {
downloadLog(selectedLog);
}
};
const totalPages = logData ? Math.ceil(logData.total / limit) : 0;
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Access Logs</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{/* Log File List */}
<div className="md:col-span-1 space-y-4">
<Card className="p-4">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Log Files</h2>
{isLoadingLogs ? (
<div className="flex justify-center p-4">
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
</div>
) : (
<div className="space-y-2">
{logs?.map((log) => (
<button
key={log.name}
onClick={() => { setSelectedLog(log.name); setPage(0); }}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors flex items-center ${
selectedLog === log.name
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800'
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300'
}`}
>
<FileText className="w-4 h-4 mr-2" />
<div className="flex-1 truncate">
<div className="font-medium">{log.name}</div>
<div className="text-xs text-gray-500">{(log.size / 1024 / 1024).toFixed(2)} MB</div>
</div>
</button>
))}
{logs?.length === 0 && (
<div className="text-sm text-gray-500 text-center py-4">No log files found</div>
)}
</div>
)}
</Card>
</div>
{/* Log Content */}
<div className="md:col-span-3 space-y-4">
{selectedLog ? (
<>
<LogFilters
search={search}
onSearchChange={(v) => { setSearch(v); setPage(0); }}
host={host}
onHostChange={(v) => { setHost(v); setPage(0); }}
status={status}
onStatusChange={(v) => { setStatus(v); setPage(0); }}
level={level}
onLevelChange={(v) => { setLevel(v); setPage(0); }}
sort={sort}
onSortChange={(v) => { setSort(v); setPage(0); }}
onRefresh={refetchContent}
onDownload={handleDownload}
isLoading={isLoadingContent}
/>
<Card className="overflow-hidden">
<LogTable
logs={logData?.logs || []}
isLoading={isLoadingContent}
/>
{/* Pagination */}
{logData && logData.total > 0 && (
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
Showing {logData.offset + 1} to {Math.min(logData.offset + limit, logData.total)} of {logData.total} entries
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 dark:text-gray-400">Page</span>
<select
value={page}
onChange={(e) => setPage(Number(e.target.value))}
className="block w-20 rounded-md border-gray-300 dark:border-gray-600 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm dark:bg-gray-700 dark:text-white py-1"
disabled={isLoadingContent}
>
{Array.from({ length: totalPages }, (_, i) => (
<option key={i} value={i}>
{i + 1}
</option>
))}
</select>
<span className="text-sm text-gray-500 dark:text-gray-400">of {totalPages}</span>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0 || isLoadingContent}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPage(p => p + 1)}
disabled={page >= totalPages - 1 || isLoadingContent}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</div>
)}
</Card>
</>
) : (
<Card className="p-8 flex flex-col items-center justify-center text-gray-500 h-64">
<FileText className="w-12 h-12 mb-4 opacity-20" />
<p>Select a log file to view contents</p>
</Card>
)}
</div>
</div>
</div>
);
};
export default Logs;
-309
View File
@@ -1,309 +0,0 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, NotificationProvider } from '../api/notifications';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react';
import { useForm } from 'react-hook-form';
const ProviderForm: React.FC<{
initialData?: Partial<NotificationProvider>;
onClose: () => void;
onSubmit: (data: any) => void;
}> = ({ initialData, onClose, onSubmit }) => {
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({
defaultValues: initialData || {
type: 'discord',
enabled: true,
config: '',
notify_proxy_hosts: true,
notify_remote_servers: true,
notify_domains: true,
notify_certs: true,
notify_uptime: true
}
});
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const testMutation = useMutation({
mutationFn: testProvider,
onSuccess: () => {
setTestStatus('success');
setTimeout(() => setTestStatus('idle'), 3000);
},
onError: () => {
setTestStatus('error');
setTimeout(() => setTestStatus('idle'), 3000);
}
});
const handleTest = () => {
const formData = watch();
testMutation.mutate(formData as any);
};
const type = watch('type');
const setTemplate = (template: string) => {
setValue('config', template);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input
{...register('name', { required: 'Name is required' })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
/>
{errors.name && <span className="text-red-500 text-xs">{errors.name.message as string}</span>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Type</label>
<select
{...register('type')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="gotify">Gotify</option>
<option value="telegram">Telegram</option>
<option value="generic">Generic Webhook (Shoutrrr)</option>
<option value="webhook">Custom Webhook (JSON)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">URL / Webhook</label>
<input
{...register('url', { required: 'URL is required' })}
placeholder="https://discord.com/api/webhooks/..."
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white sm:text-sm"
/>
{type !== 'webhook' && (
<p className="text-xs text-gray-500 mt-1">
For Shoutrrr format, see <a href="https://containrrr.dev/shoutrrr/" target="_blank" rel="noreferrer" className="text-blue-500 hover:underline">documentation</a>.
</p>
)}
</div>
{type === 'webhook' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">JSON Payload Template</label>
<div className="flex gap-2 mb-2 mt-1">
<Button type="button" size="sm" variant="secondary" onClick={() => setTemplate('{"content": "{{.Title}}: {{.Message}}"}')}>
Simple Template
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => setTemplate(`{
"embeds": [{
"title": "{{.Title}}",
"description": "{{.Message}}",
"color": 15158332,
"fields": [
{ "name": "Monitor", "value": "{{.Name}}", "inline": true },
{ "name": "Status", "value": "{{.Status}}", "inline": true },
{ "name": "Latency", "value": "{{.Latency}}ms", "inline": true }
]
}]
}`)}>
Detailed Template (Discord)
</Button>
</div>
<textarea
{...register('config')}
rows={8}
className="mt-1 block w-full font-mono text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
placeholder='{"text": "{{.Message}}"}'
/>
<p className="text-xs text-gray-500 mt-1">
Available variables: .Title, .Message, .Status, .Name, .Latency, .Time
</p>
</div>
)}
<div className="space-y-2 border-t border-gray-200 dark:border-gray-700 pt-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Notification Events</h4>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center">
<input type="checkbox" {...register('notify_proxy_hosts')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Proxy Hosts</label>
</div>
<div className="flex items-center">
<input type="checkbox" {...register('notify_remote_servers')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Remote Servers</label>
</div>
<div className="flex items-center">
<input type="checkbox" {...register('notify_domains')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Domains</label>
</div>
<div className="flex items-center">
<input type="checkbox" {...register('notify_certs')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Certificates</label>
</div>
<div className="flex items-center">
<input type="checkbox" {...register('notify_uptime')} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" />
<label className="ml-2 block text-sm text-gray-700 dark:text-gray-300">Uptime</label>
</div>
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
{...register('enabled')}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label className="ml-2 block text-sm text-gray-900 dark:text-gray-300">Enabled</label>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button
type="button"
variant="secondary"
onClick={handleTest}
disabled={testMutation.isPending}
className="min-w-[80px]"
>
{testMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> :
testStatus === 'success' ? <Check className="w-4 h-4 text-green-500 mx-auto" /> :
testStatus === 'error' ? <X className="w-4 h-4 text-red-500 mx-auto" /> :
"Test"}
</Button>
<Button type="submit">Save</Button>
</div>
</form>
);
};
const Notifications: React.FC = () => {
const queryClient = useQueryClient();
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const { data: providers, isLoading } = useQuery({
queryKey: ['notificationProviders'],
queryFn: getProviders,
});
const createMutation = useMutation({
mutationFn: createProvider,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
setIsAdding(false);
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => updateProvider(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
setEditingId(null);
},
});
const deleteMutation = useMutation({
mutationFn: deleteProvider,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notificationProviders'] });
},
});
const testMutation = useMutation({
mutationFn: testProvider,
onSuccess: () => alert('Test notification sent!'),
onError: (err: any) => alert(`Failed to send test: ${err.response?.data?.error || err.message}`),
});
if (isLoading) return <div>Loading...</div>;
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Bell className="w-6 h-6" />
Notification Providers
</h1>
<Button onClick={() => setIsAdding(true)}>
<Plus className="w-4 h-4 mr-2" />
Add Provider
</Button>
</div>
{isAdding && (
<Card className="p-6 mb-6 border-blue-500 border-2">
<h3 className="text-lg font-medium mb-4">Add New Provider</h3>
<ProviderForm
onClose={() => setIsAdding(false)}
onSubmit={(data) => createMutation.mutate(data)}
/>
</Card>
)}
<div className="grid gap-4">
{providers?.map((provider) => (
<Card key={provider.id} className="p-4">
{editingId === provider.id ? (
<ProviderForm
initialData={provider}
onClose={() => setEditingId(null)}
onSubmit={(data) => updateMutation.mutate({ id: provider.id, data })}
/>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`p-2 rounded-full ${provider.enabled ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-400'}`}>
<Bell className="w-5 h-5" />
</div>
<div>
<h3 className="font-medium text-gray-900 dark:text-white">{provider.name}</h3>
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
<span className="uppercase text-xs font-bold bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
{provider.type}
</span>
<span className="truncate max-w-xs opacity-50">{provider.url}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => testMutation.mutate(provider)}
isLoading={testMutation.isPending}
title="Send Test Notification"
>
<Send className="w-4 h-4" />
</Button>
<Button variant="secondary" size="sm" onClick={() => setEditingId(provider.id)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => {
if (confirm('Are you sure?')) deleteMutation.mutate(provider.id);
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
)}
</Card>
))}
{providers?.length === 0 && !isAdding && (
<div className="text-center py-12 text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700">
No notification providers configured.
</div>
)}
</div>
</div>
);
};
export default Notifications;
-197
View File
@@ -1,197 +0,0 @@
import { useState } from 'react'
import { Loader2, ExternalLink } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { useProxyHosts } from '../hooks/useProxyHosts'
import { getSettings } from '../api/settings'
import type { ProxyHost } from '../api/proxyHosts'
import ProxyHostForm from '../components/ProxyHostForm'
import { Switch } from '../components/ui/Switch'
export default function ProxyHosts() {
const { hosts, loading, isFetching, error, createHost, updateHost, deleteHost } = useProxyHosts()
const [showForm, setShowForm] = useState(false)
const [editingHost, setEditingHost] = useState<ProxyHost | undefined>()
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: getSettings,
})
const linkBehavior = settings?.['ui.domain_link_behavior'] || 'new_tab'
const handleDomainClick = (e: React.MouseEvent, url: string) => {
if (linkBehavior === 'new_window') {
e.preventDefault()
window.open(url, '_blank', 'noopener,noreferrer,width=1024,height=768')
}
}
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">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-white">Proxy Hosts</h1>
{isFetching && !loading && <Loader2 className="animate-spin text-blue-400" size={24} />}
</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 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.split(',').map((domain, i) => {
const url = `${host.ssl_forced ? 'https' : 'http'}://${domain.trim()}`
return (
<div key={i} className="flex items-center gap-1">
<a
href={url}
target={linkBehavior === 'same_tab' ? '_self' : '_blank'}
rel="noopener noreferrer"
onClick={(e) => handleDomainClick(e, url)}
className="hover:text-blue-400 hover:underline flex items-center gap-1"
>
{domain.trim()}
<ExternalLink size={12} className="opacity-50" />
</a>
</div>
)
})}
</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">
<div className="flex items-center gap-2">
<Switch
checked={host.enabled}
onCheckedChange={(checked) => updateHost(host.uuid, { enabled: checked })}
/>
<span className={`text-sm ${host.enabled ? 'text-green-400' : 'text-gray-400'}`}>
{host.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
</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>
)
}
-239
View File
@@ -1,239 +0,0 @@
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useRemoteServers } from '../hooks/useRemoteServers'
import type { RemoteServer } from '../api/remoteServers'
import RemoteServerForm from '../components/RemoteServerForm'
export default function RemoteServers() {
const { servers, loading, isFetching, 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">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-white">Remote Servers</h1>
{isFetching && !loading && <Loader2 className="animate-spin text-blue-400" size={24} />}
</div>
<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>
)
}
-44
View File
@@ -1,44 +0,0 @@
import { Link, Outlet, useLocation } from 'react-router-dom'
export default function Settings() {
const location = useLocation()
const isActive = (path: string) => location.pathname === path
return (
<div className="">
<div className="mb-6">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">Settings</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Manage system and account settings</p>
</div>
<div className="flex items-center gap-4 mb-6">
<Link
to="/settings/system"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive('/settings/system')
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
System
</Link>
<Link
to="/settings/account"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive('/settings/account')
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
Account
</Link>
</div>
<div className="bg-white dark:bg-dark-card border border-gray-200 dark:border-gray-800 rounded-md p-6">
<Outlet />
</div>
</div>
)
}
-166
View File
@@ -1,166 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getSetupStatus, performSetup, SetupRequest } from '../api/setup';
import client from '../api/client';
import { useAuth } from '../hooks/useAuth';
import { Input } from '../components/ui/Input';
import { Button } from '../components/ui/Button';
import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter';
import { isValidEmail } from '../utils/validation';
const Setup: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { login, isAuthenticated } = useAuth();
const [formData, setFormData] = useState<SetupRequest>({
name: '',
email: '',
password: '',
});
const [error, setError] = useState<string | null>(null);
const [emailValid, setEmailValid] = useState<boolean | null>(null);
const { data: status, isLoading: statusLoading } = useQuery({
queryKey: ['setupStatus'],
queryFn: getSetupStatus,
retry: false,
});
useEffect(() => {
if (formData.email) {
setEmailValid(isValidEmail(formData.email));
} else {
setEmailValid(null);
}
}, [formData.email]);
useEffect(() => {
// Wait for setup status to load
if (statusLoading) return;
// If setup is required, stay on this page (ignore stale auth)
if (status?.setupRequired) {
return;
}
// If setup is NOT required, redirect based on auth
if (isAuthenticated) {
navigate('/');
} else {
navigate('/login');
}
}, [status, statusLoading, isAuthenticated, navigate]);
const mutation = useMutation({
mutationFn: async (data: SetupRequest) => {
// 1. Perform Setup
await performSetup(data);
// 2. Auto Login
await client.post('/auth/login', { email: data.email, password: data.password });
// 3. Update Auth Context
await login();
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['setupStatus'] });
navigate('/');
},
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="space-y-4">
<Input
id="name"
name="name"
label="Name"
type="text"
required
placeholder="Admin User"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
<div className="relative">
<Input
id="email"
name="email"
label="Email Address"
type="email"
required
placeholder="admin@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className={emailValid === false ? 'border-red-500 focus:ring-red-500' : emailValid === true ? 'border-green-500 focus:ring-green-500' : ''}
/>
{emailValid === false && (
<p className="mt-1 text-xs text-red-500">Please enter a valid email address</p>
)}
</div>
<div className="space-y-1">
<Input
id="password"
name="password"
label="Password"
type="password"
required
placeholder="••••••••"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
<PasswordStrengthMeter password={formData.password} />
</div>
</div>
{error && (
<div className="text-red-500 text-sm text-center">
{error}
</div>
)}
<div>
<Button
type="submit"
className="w-full"
isLoading={mutation.isPending}
>
Create Admin Account
</Button>
</div>
</form>
</div>
</div>
);
};
export default Setup;
-256
View File
@@ -1,256 +0,0 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { Input } from '../components/ui/Input'
import { toast } from '../utils/toast'
import { getSettings, updateSetting } from '../api/settings'
import client from '../api/client'
import { Loader2, Server, RefreshCw, Save, Activity } from 'lucide-react'
interface HealthResponse {
status: string
service: string
version: string
git_commit: string
build_time: string
}
interface UpdateInfo {
current_version: string
latest_version: string
update_available: boolean
release_url?: string
}
export default function SystemSettings() {
const queryClient = useQueryClient()
const [caddyAdminAPI, setCaddyAdminAPI] = useState('http://localhost:2019')
const [sslProvider, setSslProvider] = useState('letsencrypt')
const [domainLinkBehavior, setDomainLinkBehavior] = useState('new_tab')
// Fetch Settings
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: getSettings,
})
// Update local state when settings load
useEffect(() => {
if (settings) {
if (settings['caddy.admin_api']) setCaddyAdminAPI(settings['caddy.admin_api'])
if (settings['caddy.ssl_provider']) setSslProvider(settings['caddy.ssl_provider'])
if (settings['ui.domain_link_behavior']) setDomainLinkBehavior(settings['ui.domain_link_behavior'])
}
}, [settings])
// Fetch Health/System Status
const { data: health, isLoading: isLoadingHealth } = useQuery({
queryKey: ['health'],
queryFn: async (): Promise<HealthResponse> => {
const response = await client.get<HealthResponse>('/health')
return response.data
},
})
// Check for Updates
const {
data: updateInfo,
refetch: checkUpdates,
isFetching: isCheckingUpdates,
} = useQuery({
queryKey: ['updates'],
queryFn: async (): Promise<UpdateInfo> => {
const response = await client.get<UpdateInfo>('/system/updates')
return response.data
},
enabled: false, // Manual trigger
})
const saveSettingsMutation = useMutation({
mutationFn: async () => {
await updateSetting('caddy.admin_api', caddyAdminAPI, 'caddy', 'string')
await updateSetting('caddy.ssl_provider', sslProvider, 'caddy', 'string')
await updateSetting('ui.domain_link_behavior', domainLinkBehavior, 'ui', 'string')
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] })
toast.success('System settings saved')
},
onError: (error: any) => {
toast.error(`Failed to save settings: ${error.message}`)
},
})
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Server className="w-8 h-8" />
System Settings
</h1>
{/* General Configuration */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">General Configuration</h2>
<div className="space-y-4">
<Input
label="Caddy Admin API Endpoint"
type="text"
value={caddyAdminAPI}
onChange={(e) => setCaddyAdminAPI(e.target.value)}
placeholder="http://localhost:2019"
/>
<p className="text-sm text-gray-500 dark:text-gray-400 -mt-2">
URL to the Caddy admin API (usually on port 2019)
</p>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
SSL Provider
</label>
<select
value={sslProvider}
onChange={(e) => setSslProvider(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 focus:border-blue-500 transition-colors"
>
<option value="letsencrypt">Let's Encrypt (Default)</option>
<option value="zerossl">ZeroSSL</option>
</select>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Choose the default Certificate Authority for SSL certificates.
</p>
</div>
<div className="w-full">
<label className="block text-sm font-medium text-gray-300 mb-1.5">
Domain Link Behavior
</label>
<select
value={domainLinkBehavior}
onChange={(e) => setDomainLinkBehavior(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 focus:border-blue-500 transition-colors"
>
<option value="same_tab">Same Tab</option>
<option value="new_tab">New Tab (Default)</option>
<option value="new_window">New Window</option>
</select>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Control how domain links open in the Proxy Hosts list.
</p>
</div>
<div className="flex justify-end">
<Button
onClick={() => saveSettingsMutation.mutate()}
isLoading={saveSettingsMutation.isPending}
>
<Save className="w-4 h-4 mr-2" />
Save Settings
</Button>
</div>
</div>
</Card>
{/* System Status */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center gap-2">
<Activity className="w-5 h-5" />
System Status
</h2>
{isLoadingHealth ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
</div>
) : health ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Service</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">{health.service}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Status</p>
<p className="text-lg font-medium text-green-600 dark:text-green-400 capitalize">
{health.status}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Version</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">{health.version}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Build Time</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">
{health.build_time || 'N/A'}
</p>
</div>
<div className="md:col-span-2">
<p className="text-sm text-gray-500 dark:text-gray-400">Git Commit</p>
<p className="text-sm font-mono text-gray-900 dark:text-white">
{health.git_commit || 'N/A'}
</p>
</div>
</div>
) : (
<p className="text-red-500">Unable to fetch system status</p>
)}
</Card>
{/* Update Check */}
<Card className="p-6">
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Software Updates</h2>
<div className="space-y-4">
{updateInfo && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Current Version</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">
{updateInfo.current_version}
</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Latest Version</p>
<p className="text-lg font-medium text-gray-900 dark:text-white">
{updateInfo.latest_version}
</p>
</div>
{updateInfo.update_available && (
<div className="md:col-span-2">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-blue-800 dark:text-blue-300 font-medium">
A new version is available!
</p>
{updateInfo.release_url && (
<a
href={updateInfo.release_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline text-sm"
>
View Release Notes
</a>
)}
</div>
</div>
)}
{!updateInfo.update_available && (
<div className="md:col-span-2">
<p className="text-green-600 dark:text-green-400">
You are running the latest version
</p>
</div>
)}
</div>
)}
<Button
onClick={() => checkUpdates()}
isLoading={isCheckingUpdates}
variant="secondary"
>
<RefreshCw className="w-4 h-4 mr-2" />
Check for Updates
</Button>
</div>
</Card>
</div>
)
}
-44
View File
@@ -1,44 +0,0 @@
import { Link, Outlet, useLocation } from 'react-router-dom'
export default function Tasks() {
const location = useLocation()
const isActive = (path: string) => location.pathname === path
return (
<div className="">
<div className="mb-6">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white">Tasks</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">Manage system tasks and view logs</p>
</div>
<div className="flex items-center gap-4 mb-6">
<Link
to="/tasks/backups"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive('/tasks/backups')
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
Backups
</Link>
<Link
to="/tasks/logs"
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive('/tasks/logs')
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
Logs
</Link>
</div>
<div className="bg-white dark:bg-dark-card border border-gray-200 dark:border-gray-800 rounded-md p-6">
<Outlet />
</div>
</div>
)
}
-120
View File
@@ -1,120 +0,0 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { getMonitors, getMonitorHistory } from '../api/uptime';
import { Activity, ArrowUp, ArrowDown } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
const MonitorCard: React.FC<{ monitor: any }> = ({ monitor }) => {
const { data: history } = useQuery({
queryKey: ['uptimeHistory', monitor.id],
queryFn: () => getMonitorHistory(monitor.id, 60),
refetchInterval: 60000,
});
const isUp = monitor.status === 'up';
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 border-l-4 ${isUp ? 'border-l-green-500' : 'border-l-red-500'}`}>
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-semibold text-lg text-gray-900 dark:text-white">{monitor.name}</h3>
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
<a href={`http://${monitor.url}`} target="_blank" rel="noreferrer" className="hover:underline">
{monitor.url}
</a>
<span className="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-xs">
{monitor.type.toUpperCase()}
</span>
</div>
</div>
<div className={`flex items-center px-3 py-1 rounded-full text-sm font-medium ${
isUp ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{isUp ? <ArrowUp className="w-4 h-4 mr-1" /> : <ArrowDown className="w-4 h-4 mr-1" />}
{monitor.status.toUpperCase()}
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Latency</div>
<div className="text-lg font-mono font-medium text-gray-900 dark:text-white">
{monitor.latency}ms
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Check</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{monitor.last_check ? formatDistanceToNow(new Date(monitor.last_check), { addSuffix: true }) : 'Never'}
</div>
</div>
</div>
{/* Heartbeat Bar (Last 60 checks / 1 Hour) */}
<div className="flex gap-[2px] h-8 items-end" title="Last 60 checks (1 Hour)">
{/* Fill with empty bars if not enough history to keep alignment right-aligned */}
{Array.from({ length: Math.max(0, 60 - (history?.length || 0)) }).map((_, i) => (
<div key={`empty-${i}`} className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-sm h-full opacity-50" />
))}
{history?.slice().reverse().map((beat: any, i: number) => (
<div
key={i}
className={`flex-1 rounded-sm transition-all duration-200 hover:scale-110 ${
beat.status === 'up'
? 'bg-green-400 dark:bg-green-500 hover:bg-green-300'
: 'bg-red-400 dark:bg-red-500 hover:bg-red-300'
}`}
style={{ height: '100%' }}
title={`${new Date(beat.created_at).toLocaleString()}
Status: ${beat.status.toUpperCase()}
Latency: ${beat.latency}ms
Message: ${beat.message}`}
/>
))}
{(!history || history.length === 0) && (
<div className="absolute w-full text-center text-xs text-gray-400">No history available</div>
)}
</div>
</div>
);
};
const Uptime: React.FC = () => {
const { data: monitors, isLoading } = useQuery({
queryKey: ['monitors'],
queryFn: getMonitors,
refetchInterval: 30000,
});
if (isLoading) {
return <div className="p-8 text-center">Loading monitors...</div>;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<Activity className="w-6 h-6" />
Uptime Monitoring
</h1>
<div className="text-sm text-gray-500">
Auto-refreshing every 30s
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{monitors?.map((monitor) => (
<MonitorCard key={monitor.id} monitor={monitor} />
))}
{monitors?.length === 0 && (
<div className="col-span-full text-center py-12 text-gray-500">
No monitors found. Add a Proxy Host to start monitoring.
</div>
)}
</div>
</div>
);
};
export default Uptime;
-146
View File
@@ -1,146 +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 AuthContext so useAuth works in tests
vi.mock('../../hooks/useAuth', () => ({
useAuth: () => ({
login: vi.fn(),
logout: vi.fn(),
isAuthenticated: false,
isLoading: false,
user: null,
}),
}));
// Mock API client
vi.mock('../../api/client', () => ({
default: {
post: vi.fn().mockResolvedValue({ data: {} }),
get: vi.fn().mockResolvedValue({ data: {} }),
},
}));
// 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',
});
});
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/');
});
});
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();
});
});
});