chore: clean cache
This commit is contained in:
@@ -1,555 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/Button';
|
||||
import { Input } from './ui/Input';
|
||||
import { Switch } from './ui/Switch';
|
||||
import { X, Plus, ExternalLink, Shield, AlertTriangle, Info, Download, Trash2 } from 'lucide-react';
|
||||
import type { AccessList, AccessListRule } from '../api/accessLists';
|
||||
import { SECURITY_PRESETS, calculateTotalIPs, formatIPCount, type SecurityPreset } from '../data/securityPresets';
|
||||
import { getMyIP } from '../api/system';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface AccessListFormProps {
|
||||
initialData?: AccessList;
|
||||
onSubmit: (data: AccessListFormData) => void;
|
||||
onCancel: () => void;
|
||||
onDelete?: () => void;
|
||||
isLoading?: boolean;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export interface AccessListFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist';
|
||||
ip_rules: string;
|
||||
country_codes: string;
|
||||
local_network_only: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const COUNTRIES = [
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'GB', name: 'United Kingdom' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'NO', name: 'Norway' },
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'CZ', name: 'Czech Republic' },
|
||||
{ code: 'AT', name: 'Austria' },
|
||||
{ code: 'CH', name: 'Switzerland' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'NZ', name: 'New Zealand' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'CN', name: 'China' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'MX', name: 'Mexico' },
|
||||
{ code: 'AR', name: 'Argentina' },
|
||||
{ code: 'RU', name: 'Russia' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
{ code: 'TR', name: 'Turkey' },
|
||||
{ code: 'IL', name: 'Israel' },
|
||||
{ code: 'SA', name: 'Saudi Arabia' },
|
||||
{ code: 'AE', name: 'United Arab Emirates' },
|
||||
{ code: 'EG', name: 'Egypt' },
|
||||
{ code: 'ZA', name: 'South Africa' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'TH', name: 'Thailand' },
|
||||
{ code: 'ID', name: 'Indonesia' },
|
||||
{ code: 'PH', name: 'Philippines' },
|
||||
{ code: 'VN', name: 'Vietnam' },
|
||||
];
|
||||
|
||||
export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLoading, isDeleting }: AccessListFormProps) {
|
||||
const [formData, setFormData] = useState<AccessListFormData>({
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
type: initialData?.type || 'whitelist',
|
||||
ip_rules: initialData?.ip_rules || '',
|
||||
country_codes: initialData?.country_codes || '',
|
||||
local_network_only: initialData?.local_network_only || false,
|
||||
enabled: initialData?.enabled ?? true,
|
||||
});
|
||||
|
||||
const [ipRules, setIPRules] = useState<AccessListRule[]>(() => {
|
||||
if (initialData?.ip_rules) {
|
||||
try {
|
||||
return JSON.parse(initialData.ip_rules);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [selectedCountries, setSelectedCountries] = useState<string[]>(() => {
|
||||
if (initialData?.country_codes) {
|
||||
return initialData.country_codes.split(',').map((c) => c.trim());
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [newIP, setNewIP] = useState('');
|
||||
const [newIPDescription, setNewIPDescription] = useState('');
|
||||
const [showPresets, setShowPresets] = useState(false);
|
||||
const [loadingMyIP, setLoadingMyIP] = useState(false);
|
||||
|
||||
const isGeoType = formData.type.startsWith('geo_');
|
||||
const isIPType = !isGeoType;
|
||||
|
||||
// Calculate total IPs in current rules
|
||||
const totalIPs = isIPType && !formData.local_network_only
|
||||
? calculateTotalIPs(ipRules.map(r => r.cidr))
|
||||
: 0;
|
||||
|
||||
const handleAddIP = () => {
|
||||
if (!newIP.trim()) return;
|
||||
|
||||
const newRule: AccessListRule = {
|
||||
cidr: newIP.trim(),
|
||||
description: newIPDescription.trim(),
|
||||
};
|
||||
|
||||
const updatedRules = [...ipRules, newRule];
|
||||
setIPRules(updatedRules);
|
||||
setNewIP('');
|
||||
setNewIPDescription('');
|
||||
};
|
||||
|
||||
const handleRemoveIP = (index: number) => {
|
||||
setIPRules(ipRules.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleAddCountry = (countryCode: string) => {
|
||||
if (!selectedCountries.includes(countryCode)) {
|
||||
setSelectedCountries([...selectedCountries, countryCode]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCountry = (countryCode: string) => {
|
||||
setSelectedCountries(selectedCountries.filter((c) => c !== countryCode));
|
||||
};
|
||||
|
||||
const handleApplyPreset = (preset: SecurityPreset) => {
|
||||
if (preset.type === 'geo_blacklist' && preset.countryCodes) {
|
||||
setFormData({ ...formData, type: 'geo_blacklist' });
|
||||
setSelectedCountries([...new Set([...selectedCountries, ...preset.countryCodes])]);
|
||||
toast.success(`Applied preset: ${preset.name}`);
|
||||
} else if (preset.type === 'blacklist' && preset.ipRanges) {
|
||||
setFormData({ ...formData, type: 'blacklist' });
|
||||
const newRules = preset.ipRanges.filter(
|
||||
(newRule) => !ipRules.some((existing) => existing.cidr === newRule.cidr)
|
||||
);
|
||||
setIPRules([...ipRules, ...newRules]);
|
||||
toast.success(`Applied preset: ${preset.name} (${newRules.length} rules added)`);
|
||||
}
|
||||
setShowPresets(false);
|
||||
};
|
||||
|
||||
const handleGetMyIP = async () => {
|
||||
setLoadingMyIP(true);
|
||||
try {
|
||||
const result = await getMyIP();
|
||||
setNewIP(result.ip);
|
||||
toast.success(`Your IP: ${result.ip} (from ${result.source})`);
|
||||
} catch {
|
||||
toast.error('Failed to fetch your IP address');
|
||||
} finally {
|
||||
setLoadingMyIP(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const data: AccessListFormData = {
|
||||
...formData,
|
||||
ip_rules: isIPType && !formData.local_network_only ? JSON.stringify(ipRules) : '',
|
||||
country_codes: isGeoType ? selectedCountries.join(',') : '',
|
||||
};
|
||||
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Name *
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="My Access List"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
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 htmlFor="type" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Type *
|
||||
<a
|
||||
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-blue-400 hover:text-blue-300 text-xs"
|
||||
>
|
||||
<ExternalLink className="inline h-3 w-3" /> Best Practices
|
||||
</a>
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
value={formData.type}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, type: e.target.value as 'whitelist' | 'blacklist' | 'geo_whitelist' | 'geo_blacklist', local_network_only: false })
|
||||
}
|
||||
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="whitelist">🛡️ IP Whitelist (Allow Only)</option>
|
||||
<option value="blacklist">� IP Blacklist (Block Only) - Recommended</option>
|
||||
<option value="geo_whitelist">🌍 Geo Whitelist (Allow Countries)</option>
|
||||
<option value="geo_blacklist">🌍 Geo Blacklist (Block Countries) - Recommended</option>
|
||||
</select>
|
||||
{(formData.type === 'blacklist' || formData.type === 'geo_blacklist') && (
|
||||
<div className="mt-2 flex items-start gap-2 p-3 bg-blue-900/20 border border-blue-700/50 rounded-lg">
|
||||
<Info className="h-4 w-4 text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-blue-300">
|
||||
<strong>Recommended:</strong> Block lists are safer than allow lists. They block known bad actors while allowing everyone else access, preventing lockouts.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Presets */}
|
||||
{(formData.type === 'blacklist' || formData.type === 'geo_blacklist') && (
|
||||
<div className="bg-gray-800/50 border border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-green-400" />
|
||||
<h3 className="text-sm font-medium text-gray-300">Security Presets</h3>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowPresets(!showPresets)}
|
||||
>
|
||||
{showPresets ? 'Hide' : 'Show'} Presets
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showPresets && (
|
||||
<div className="space-y-3 mt-4">
|
||||
<p className="text-xs text-gray-400 mb-3">
|
||||
Quick-start templates based on threat intelligence feeds and best practices. Hover over (i) for data sources.
|
||||
</p>
|
||||
|
||||
{/* Security Category - filter by current type */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">Recommended Security Presets</h4>
|
||||
<div className="space-y-2">
|
||||
{SECURITY_PRESETS.filter(p => p.category === 'security' && p.type === formData.type).map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="bg-gray-900 border border-gray-700 rounded-lg p-3 hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h5 className="text-sm font-medium text-white">{preset.name}</h5>
|
||||
<a
|
||||
href={preset.dataSourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-blue-400"
|
||||
title={`Data from: ${preset.dataSource}`}
|
||||
>
|
||||
<Info className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">{preset.description}</p>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-gray-500">~{preset.estimatedIPs} IPs</span>
|
||||
<span className="text-gray-600">|</span>
|
||||
<span className="text-gray-500">{preset.dataSource}</span>
|
||||
</div>
|
||||
{preset.warning && (
|
||||
<div className="flex items-start gap-1 mt-2 text-xs text-orange-400">
|
||||
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{preset.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => handleApplyPreset(preset)}
|
||||
className="ml-3"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Category - filter by current type */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-400 uppercase mb-2">Advanced Presets</h4>
|
||||
<div className="space-y-2">
|
||||
{SECURITY_PRESETS.filter(p => p.category === 'advanced' && p.type === formData.type).map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="bg-gray-900 border border-gray-700 rounded-lg p-3 hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h5 className="text-sm font-medium text-white">{preset.name}</h5>
|
||||
<a
|
||||
href={preset.dataSourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-blue-400"
|
||||
title={`Data from: ${preset.dataSource}`}
|
||||
>
|
||||
<Info className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">{preset.description}</p>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span className="text-gray-500">~{preset.estimatedIPs} IPs</span>
|
||||
<span className="text-gray-600">|</span>
|
||||
<span className="text-gray-500">{preset.dataSource}</span>
|
||||
</div>
|
||||
{preset.warning && (
|
||||
<div className="flex items-start gap-1 mt-2 text-xs text-orange-400">
|
||||
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{preset.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleApplyPreset(preset)}
|
||||
className="ml-3"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300">Enabled</label>
|
||||
<p className="text-xs text-gray-500">Apply this access list to hosts</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.enabled}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IP-based Rules */}
|
||||
{isIPType && (
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300">Local Network Only (RFC1918)</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Allow only private network IPs (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.local_network_only}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, local_network_only: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!formData.local_network_only && (
|
||||
<>
|
||||
<div className="mb-2 text-xs text-gray-500">
|
||||
Note: IP-based blocklists (botnets, cloud scanners, VPN ranges) are better handled by CrowdSec, WAF, or rate limiting. Use IP-based ACLs sparingly for static or known ranges.
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-300">IP Addresses / CIDR Ranges</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleGetMyIP}
|
||||
disabled={loadingMyIP}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{loadingMyIP ? 'Loading...' : 'Get My IP'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newIP}
|
||||
onChange={(e) => setNewIP(e.target.value)}
|
||||
placeholder="192.168.1.0/24 or 10.0.0.1"
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddIP())}
|
||||
/>
|
||||
<Input
|
||||
value={newIPDescription}
|
||||
onChange={(e) => setNewIPDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddIP())}
|
||||
/>
|
||||
<Button type="button" onClick={handleAddIP} size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{totalIPs > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<Info className="h-3 w-3" />
|
||||
<span>Current rules cover approximately <strong className="text-white">{formatIPCount(totalIPs)}</strong> IP addresses</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ipRules.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{ipRules.map((rule, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-gray-600 bg-gray-700"
|
||||
>
|
||||
<div>
|
||||
<p className="font-mono text-sm text-white">{rule.cidr}</p>
|
||||
{rule.description && (
|
||||
<p className="text-xs text-gray-400">{rule.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveIP(index)}
|
||||
className="text-gray-400 hover:text-red-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geo-blocking Rules */}
|
||||
{isGeoType && (
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Select Countries</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
handleAddCountry(e.target.value);
|
||||
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="">Add a country...</option>
|
||||
{COUNTRIES.filter((c) => !selectedCountries.includes(c.code)).map((country) => (
|
||||
<option key={country.code} value={country.code}>
|
||||
{country.name} ({country.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedCountries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedCountries.map((code) => {
|
||||
const country = COUNTRIES.find((c) => c.code === code);
|
||||
return (
|
||||
<span
|
||||
key={code}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-gray-700 text-gray-200 border border-gray-600"
|
||||
>
|
||||
{country?.name || code}
|
||||
<X
|
||||
className="h-3 w-3 cursor-pointer hover:text-red-400"
|
||||
onClick={() => handleRemoveCountry(code)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between gap-2">
|
||||
<div>
|
||||
{initialData && onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={onDelete}
|
||||
disabled={isLoading || isDeleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onCancel} disabled={isLoading || isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={isLoading || isDeleting}>
|
||||
{isLoading ? 'Saving...' : initialData ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useAccessLists } from '../hooks/useAccessLists';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
interface AccessListSelectorProps {
|
||||
value: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
}
|
||||
|
||||
export default function AccessListSelector({ value, onChange }: AccessListSelectorProps) {
|
||||
const { data: accessLists } = useAccessLists();
|
||||
|
||||
const selectedACL = accessLists?.find((acl) => acl.id === value);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Access Control List
|
||||
<span className="text-gray-500 font-normal ml-2">(Optional)</span>
|
||||
</label>
|
||||
<select
|
||||
value={value || 0}
|
||||
onChange={(e) => onChange(parseInt(e.target.value) || null)}
|
||||
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={0}>No Access Control (Public)</option>
|
||||
{accessLists
|
||||
?.filter((acl) => acl.enabled)
|
||||
.map((acl) => (
|
||||
<option key={acl.id} value={acl.id}>
|
||||
{acl.name} ({acl.type.replace('_', ' ')})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{selectedACL && (
|
||||
<div className="mt-2 p-3 bg-gray-800 border border-gray-700 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-gray-200">{selectedACL.name}</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-700 border border-gray-600 rounded">
|
||||
{selectedACL.type.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
{selectedACL.description && (
|
||||
<p className="text-xs text-gray-400 mb-2">{selectedACL.description}</p>
|
||||
)}
|
||||
{selectedACL.local_network_only && (
|
||||
<div className="text-xs text-blue-400">
|
||||
🏠 Local Network Only (RFC1918)
|
||||
</div>
|
||||
)}
|
||||
{selectedACL.type.startsWith('geo_') && selectedACL.country_codes && (
|
||||
<div className="text-xs text-gray-400">
|
||||
🌍 Countries: {selectedACL.country_codes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Restrict access based on IP address, CIDR ranges, or geographic location.{' '}
|
||||
<a href="/security/access-lists" className="text-blue-400 hover:underline">
|
||||
Manage lists
|
||||
</a>
|
||||
{' • '}
|
||||
<a
|
||||
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="inline h-3 w-3" />
|
||||
Best Practices
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { useCertificates } from '../hooks/useCertificates'
|
||||
import { deleteCertificate } from '../api/certificates'
|
||||
import { useProxyHosts } from '../hooks/useProxyHosts'
|
||||
import { createBackup } from '../api/backups'
|
||||
import { LoadingSpinner, ConfigReloadOverlay } from './LoadingStates'
|
||||
import { toast } from '../utils/toast'
|
||||
|
||||
type SortColumn = 'name' | 'expires'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export default function CertificateList() {
|
||||
const { certificates, isLoading, error } = useCertificates()
|
||||
const { hosts } = useProxyHosts()
|
||||
const queryClient = useQueryClient()
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
// Perform backup before actual deletion
|
||||
mutationFn: async (id: number) => {
|
||||
await createBackup()
|
||||
await deleteCertificate(id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['proxyHosts'] })
|
||||
toast.success('Certificate deleted')
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(`Failed to delete certificate: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const sortedCertificates = useMemo(() => {
|
||||
return [...certificates].sort((a, b) => {
|
||||
let comparison = 0
|
||||
|
||||
switch (sortColumn) {
|
||||
case 'name': {
|
||||
const aName = (a.name || a.domain || '').toLowerCase()
|
||||
const bName = (b.name || b.domain || '').toLowerCase()
|
||||
comparison = aName.localeCompare(bName)
|
||||
break
|
||||
}
|
||||
case 'expires': {
|
||||
const aDate = new Date(a.expires_at).getTime()
|
||||
const bDate = new Date(b.expires_at).getTime()
|
||||
comparison = aDate - bDate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}, [certificates, sortColumn, sortDirection])
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortColumn(column)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const SortIcon = ({ column }: { column: SortColumn }) => {
|
||||
if (sortColumn !== column) return null
|
||||
return sortDirection === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
if (error) return <div className="text-red-500">Failed to load certificates</div>
|
||||
|
||||
return (
|
||||
<>
|
||||
{deleteMutation.isPending && (
|
||||
<ConfigReloadOverlay
|
||||
message="Returning to shore..."
|
||||
submessage="Certificate departure in progress"
|
||||
type="charon"
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
onClick={() => handleSort('name')}
|
||||
className="px-6 py-3 cursor-pointer hover:text-white transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Name
|
||||
<SortIcon column="name" />
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3">Domain</th>
|
||||
<th className="px-6 py-3">Issuer</th>
|
||||
<th
|
||||
onClick={() => handleSort('expires')}
|
||||
className="px-6 py-3 cursor-pointer hover:text-white transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
Expires
|
||||
<SortIcon column="expires" />
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3">Status</th>
|
||||
<th className="px-6 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{certificates.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
|
||||
No certificates found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedCertificates.map((cert) => (
|
||||
<tr key={cert.id || cert.domain} className="hover:bg-gray-800/50 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-white">{cert.name || '-'}</td>
|
||||
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{cert.issuer}</span>
|
||||
{cert.issuer?.toLowerCase().includes('staging') && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 rounded">
|
||||
STAGING
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
<td className="px-6 py-4">
|
||||
{cert.id && (cert.provider === 'custom' || cert.issuer?.toLowerCase().includes('staging')) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// Determine if certificate is in use by any proxy host
|
||||
const inUse = hosts.some(h => {
|
||||
const cid = h.certificate_id ?? h.certificate?.id
|
||||
return cid === cert.id
|
||||
})
|
||||
|
||||
if (inUse) {
|
||||
toast.error('Certificate cannot be deleted because it is in use by a proxy host')
|
||||
return
|
||||
}
|
||||
|
||||
// Allow deletion for custom/staging certs not in use (status check removed)
|
||||
const message = cert.provider === 'custom'
|
||||
? 'Are you sure you want to delete this certificate? This will create a backup before deleting.'
|
||||
: 'Delete this staging certificate? It will be regenerated on next request.'
|
||||
if (confirm(message)) {
|
||||
deleteMutation.mutate(cert.id!)
|
||||
}
|
||||
}}
|
||||
className="text-red-400 hover:text-red-300 transition-colors"
|
||||
title={cert.provider === 'custom' ? 'Delete Certificate' : 'Delete Staging Certificate'}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</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',
|
||||
untrusted: 'bg-orange-900/30 text-orange-400 border-orange-800',
|
||||
}
|
||||
|
||||
const labels = {
|
||||
valid: 'Valid',
|
||||
expiring: 'Expiring Soon',
|
||||
expired: 'Expired',
|
||||
untrusted: 'Untrusted (Staging)',
|
||||
}
|
||||
|
||||
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,349 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react'
|
||||
|
||||
interface HostPreview {
|
||||
domain_names: string
|
||||
name?: string
|
||||
forward_scheme?: string
|
||||
forward_host?: string
|
||||
forward_port?: number
|
||||
ssl_forced?: boolean
|
||||
websocket_support?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ConflictDetail {
|
||||
existing: {
|
||||
forward_scheme: string
|
||||
forward_host: string
|
||||
forward_port: number
|
||||
ssl_forced: boolean
|
||||
websocket: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
imported: {
|
||||
forward_scheme: string
|
||||
forward_host: string
|
||||
forward_port: number
|
||||
ssl_forced: boolean
|
||||
websocket: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
hosts: HostPreview[]
|
||||
conflicts: string[]
|
||||
conflictDetails?: Record<string, ConflictDetail>
|
||||
errors: string[]
|
||||
caddyfileContent?: string
|
||||
onCommit: (resolutions: Record<string, string>, names: Record<string, string>) => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ImportReviewTable({ hosts, conflicts, conflictDetails, errors, caddyfileContent, 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 [names, setNames] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {}
|
||||
hosts.forEach((h) => {
|
||||
// Default name to domain name (first domain if comma-separated)
|
||||
init[h.domain_names] = h.name || h.domain_names.split(',')[0].trim()
|
||||
})
|
||||
return init
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showSource, setShowSource] = useState(false)
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleCommit = async () => {
|
||||
// Validate all names are filled
|
||||
const emptyNames = hosts.filter(h => !names[h.domain_names]?.trim())
|
||||
if (emptyNames.length > 0) {
|
||||
setError(`Please provide a name for all hosts. Missing: ${emptyNames.map(h => h.domain_names).join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onCommit(resolutions, names)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to commit import')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{caddyfileContent && (
|
||||
<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 cursor-pointer" onClick={() => setShowSource(!showSource)}>
|
||||
<h2 className="text-lg font-semibold text-white">Source Caddyfile Content</h2>
|
||||
<span className="text-gray-400 text-sm">{showSource ? 'Hide' : 'Show'}</span>
|
||||
</div>
|
||||
{showSource && (
|
||||
<div className="p-4 bg-gray-900 overflow-x-auto">
|
||||
<pre className="text-xs text-gray-300 font-mono whitespace-pre-wrap">{caddyfileContent}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
Name
|
||||
</th>
|
||||
<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">
|
||||
Status
|
||||
</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) => {
|
||||
const domain = h.domain_names
|
||||
const hasConflict = conflicts.includes(domain)
|
||||
const isExpanded = expandedRows.has(domain)
|
||||
const details = conflictDetails?.[domain]
|
||||
|
||||
return (
|
||||
<React.Fragment key={domain}>
|
||||
<tr className="hover:bg-gray-900/50">
|
||||
<td className="px-6 py-4">
|
||||
<input
|
||||
type="text"
|
||||
value={names[domain] || ''}
|
||||
onChange={e => setNames({ ...names, [domain]: e.target.value })}
|
||||
placeholder="Enter name"
|
||||
className={`w-full bg-gray-900 border rounded px-3 py-1.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
!names[domain]?.trim() ? 'border-red-500' : 'border-gray-700'
|
||||
}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasConflict && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const newExpanded = new Set(expandedRows)
|
||||
if (isExpanded) newExpanded.delete(domain)
|
||||
else newExpanded.add(domain)
|
||||
setExpandedRows(newExpanded)
|
||||
}}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</button>
|
||||
)}
|
||||
<div className="text-sm font-medium text-white">{domain}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{hasConflict ? (
|
||||
<span className="flex items-center gap-1 text-yellow-400 text-xs">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Conflict
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{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-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="keep">Keep Existing (Skip Import)</option>
|
||||
<option value="overwrite">Replace with Imported</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">Will be imported</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{hasConflict && isExpanded && details && (
|
||||
<tr className="bg-gray-900/30">
|
||||
<td colSpan={4} className="px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Existing Configuration */}
|
||||
<div className="border border-blue-500/30 rounded-lg p-4 bg-blue-900/10">
|
||||
<h4 className="text-sm font-semibold text-blue-400 mb-3 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Current Configuration
|
||||
</h4>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-400">Target:</dt>
|
||||
<dd className="text-white font-mono">
|
||||
{details.existing.forward_scheme}://{details.existing.forward_host}:{details.existing.forward_port}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-400">SSL Forced:</dt>
|
||||
<dd className={details.existing.ssl_forced ? 'text-green-400' : 'text-gray-400'}>
|
||||
{details.existing.ssl_forced ? 'Yes' : 'No'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-400">WebSocket:</dt>
|
||||
<dd className={details.existing.websocket ? 'text-green-400' : 'text-gray-400'}>
|
||||
{details.existing.websocket ? 'Enabled' : 'Disabled'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-400">Status:</dt>
|
||||
<dd className={details.existing.enabled ? 'text-green-400' : 'text-red-400'}>
|
||||
{details.existing.enabled ? 'Enabled' : 'Disabled'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Imported Configuration */}
|
||||
<div className="border border-purple-500/30 rounded-lg p-4 bg-purple-900/10">
|
||||
<h4 className="text-sm font-semibold text-purple-400 mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Imported Configuration
|
||||
</h4>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-400">Target:</dt>
|
||||
<dd className={`font-mono ${
|
||||
details.imported.forward_host !== details.existing.forward_host ||
|
||||
details.imported.forward_port !== details.existing.forward_port ||
|
||||
details.imported.forward_scheme !== details.existing.forward_scheme
|
||||
? 'text-yellow-400 font-semibold'
|
||||
: 'text-white'
|
||||
}`}>
|
||||
{details.imported.forward_scheme}://{details.imported.forward_host}:{details.imported.forward_port}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-400">SSL Forced:</dt>
|
||||
<dd className={`${
|
||||
details.imported.ssl_forced !== details.existing.ssl_forced
|
||||
? 'text-yellow-400 font-semibold'
|
||||
: details.imported.ssl_forced ? 'text-green-400' : 'text-gray-400'
|
||||
}`}>
|
||||
{details.imported.ssl_forced ? 'Yes' : 'No'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-400">WebSocket:</dt>
|
||||
<dd className={`${
|
||||
details.imported.websocket !== details.existing.websocket
|
||||
? 'text-yellow-400 font-semibold'
|
||||
: details.imported.websocket ? 'text-green-400' : 'text-gray-400'
|
||||
}`}>
|
||||
{details.imported.websocket ? 'Enabled' : 'Disabled'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-400">Status:</dt>
|
||||
<dd className="text-gray-400">
|
||||
(Imported hosts are disabled by default)
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendation */}
|
||||
<div className="bg-gray-800/50 rounded-lg p-3 border-l-4 border-blue-500">
|
||||
<p className="text-sm text-gray-300">
|
||||
<strong className="text-blue-400">💡 Recommendation:</strong>{' '}
|
||||
{getRecommendation(details)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getRecommendation(details: ConflictDetail): string {
|
||||
const hasTargetChange =
|
||||
details.imported.forward_host !== details.existing.forward_host ||
|
||||
details.imported.forward_port !== details.existing.forward_port ||
|
||||
details.imported.forward_scheme !== details.existing.forward_scheme
|
||||
|
||||
const hasConfigChange =
|
||||
details.imported.ssl_forced !== details.existing.ssl_forced ||
|
||||
details.imported.websocket !== details.existing.websocket
|
||||
|
||||
if (hasTargetChange) {
|
||||
return 'The imported configuration points to a different backend server. Choose "Replace" if you want to update the target, or "Keep Existing" if the current setup is correct.'
|
||||
}
|
||||
|
||||
if (hasConfigChange) {
|
||||
return 'The imported configuration has different SSL or WebSocket settings. Choose "Replace" to update these settings, or "Keep Existing" to maintain current configuration.'
|
||||
}
|
||||
|
||||
return 'The configurations are identical. You can safely keep the existing configuration.'
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { uploadCaddyfilesMulti } from '../api/import'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onUploaded?: () => void
|
||||
}
|
||||
|
||||
export default function ImportSitesModal({ visible, onClose, onUploaded }: Props) {
|
||||
const [sites, setSites] = useState<string[]>([''])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const setSite = (index: number, value: string) => {
|
||||
const s = [...sites]
|
||||
s[index] = value
|
||||
setSites(s)
|
||||
}
|
||||
|
||||
const addSite = () => setSites(prev => [...prev, ''])
|
||||
const removeSite = (index: number) => setSites(prev => prev.filter((_, i) => i !== index))
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const cleaned = sites.map(s => s || '')
|
||||
await uploadCaddyfilesMulti(cleaned)
|
||||
setLoading(false)
|
||||
if (onUploaded) onUploaded()
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
setError(msg || 'Upload failed')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div className="relative bg-dark-card rounded-lg p-6 w-[900px] max-w-full">
|
||||
<h3 className="text-xl font-semibold text-white mb-4">Multi-site Import</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">Add each site's Caddyfile content separately, then parse them together.</p>
|
||||
|
||||
<div className="space-y-4 max-h-[60vh] overflow-auto mb-4">
|
||||
{sites.map((s, idx) => (
|
||||
<div key={idx} className="border border-gray-800 rounded-lg p-3">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="text-sm text-gray-300">Site {idx + 1}</div>
|
||||
<div>
|
||||
{sites.length > 1 && (
|
||||
<button
|
||||
onClick={() => removeSite(idx)}
|
||||
className="text-red-400 text-sm hover:underline mr-2"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={s}
|
||||
onChange={e => setSite(idx, e.target.value)}
|
||||
placeholder={`example.com {\n reverse_proxy localhost:8080\n}`}
|
||||
className="w-full h-48 bg-gray-900 border border-gray-700 rounded-lg p-3 text-white font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-2 rounded mb-4">{error}</div>}
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={addSite} className="px-4 py-2 bg-gray-800 text-white rounded">+ Add site</button>
|
||||
<button onClick={onClose} className="px-4 py-2 bg-gray-700 text-white rounded">Cancel</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-active text-white rounded disabled:opacity-60"
|
||||
>
|
||||
{loading ? 'Processing...' : 'Parse and Review'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
import { Button } from './ui/Button'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { checkHealth } from '../api/health'
|
||||
import { getFeatureFlags } from '../api/featureFlags'
|
||||
import NotificationCenter from './NotificationCenter'
|
||||
import SystemStatus from './SystemStatus'
|
||||
import { Menu, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
name: string
|
||||
path?: string
|
||||
icon?: string
|
||||
children?: NavItem[]
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation()
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
const saved = localStorage.getItem('sidebarCollapsed')
|
||||
return saved ? JSON.parse(saved) : false
|
||||
})
|
||||
const [expandedMenus, setExpandedMenus] = useState<string[]>([])
|
||||
const { logout, user } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(isCollapsed))
|
||||
}, [isCollapsed])
|
||||
|
||||
const toggleMenu = (name: string) => {
|
||||
setExpandedMenus(prev =>
|
||||
prev.includes(name)
|
||||
? prev.filter(item => item !== name)
|
||||
: [...prev, name]
|
||||
)
|
||||
}
|
||||
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: checkHealth,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
})
|
||||
|
||||
const { data: featureFlags } = useQuery({
|
||||
queryKey: ['feature-flags'],
|
||||
queryFn: getFeatureFlags,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
})
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{ name: 'Dashboard', path: '/', icon: '📊' },
|
||||
{ name: 'Proxy Hosts', path: '/proxy-hosts', icon: '🌐' },
|
||||
{ name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' },
|
||||
{ name: 'Domains', path: '/domains', icon: '🌍' },
|
||||
{ name: 'Certificates', path: '/certificates', icon: '🔒' },
|
||||
{ name: 'Uptime', path: '/uptime', icon: '📈' },
|
||||
{ name: 'Cerberus', path: '/security', icon: '🛡️', children: [
|
||||
{ name: 'Dashboard', path: '/security', icon: '🛡️' },
|
||||
{ name: 'CrowdSec', path: '/security/crowdsec', icon: '🛡️' },
|
||||
{ name: 'Access Lists', path: '/security/access-lists', icon: '🔒' },
|
||||
{ name: 'Rate Limiting', path: '/security/rate-limiting', icon: '⚡' },
|
||||
{ name: 'WAF (Coraza)', path: '/security/waf', icon: '🛡️' },
|
||||
]},
|
||||
{ name: 'Notifications', path: '/notifications', icon: '🔔' },
|
||||
// Import group moved under Tasks
|
||||
{
|
||||
name: 'Settings',
|
||||
path: '/settings',
|
||||
icon: '⚙️',
|
||||
children: [
|
||||
{ name: 'System', path: '/settings/system', icon: '⚙️' },
|
||||
{ name: 'Email (SMTP)', path: '/settings/smtp', icon: '📧' },
|
||||
{ name: 'Admin Account', path: '/settings/account', icon: '🛡️' },
|
||||
{ name: 'Account Management', path: '/settings/account-management', icon: '👥' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Tasks',
|
||||
path: '/tasks',
|
||||
icon: '📋',
|
||||
children: [
|
||||
{
|
||||
name: 'Import',
|
||||
path: '/tasks/import',
|
||||
icon: '📥',
|
||||
children: [
|
||||
{ name: 'Caddyfile', path: '/tasks/import/caddyfile', icon: '📥' },
|
||||
{ name: 'CrowdSec', path: '/tasks/import/crowdsec', icon: '🛡️' },
|
||||
]
|
||||
},
|
||||
{ name: 'Backups', path: '/tasks/backups', icon: '💾' },
|
||||
{ name: 'Logs', path: '/tasks/logs', icon: '📝' },
|
||||
]
|
||||
},
|
||||
].filter(item => {
|
||||
// Optional Features Logic
|
||||
// Default to visible (true) if flags are loading or undefined
|
||||
if (item.name === 'Uptime') return featureFlags?.['feature.uptime.enabled'] !== false
|
||||
if (item.name === 'Cerberus') return featureFlags?.['feature.cerberus.enabled'] !== false
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-light-bg 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">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" onClick={() => setMobileSidebarOpen(!mobileSidebarOpen)} data-testid="mobile-menu-toggle">
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
<img src="/logo.png" alt="Charon" className="h-10 w-auto" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationCenter />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={`
|
||||
fixed lg:fixed inset-y-0 left-0 z-30 transform transition-all duration-200 ease-in-out
|
||||
bg-white dark:bg-dark-sidebar border-r border-gray-200 dark:border-gray-800 flex flex-col
|
||||
${mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
${isCollapsed ? 'w-20' : 'w-64'}
|
||||
`}>
|
||||
<div className={`h-20 flex items-center justify-center border-b border-gray-200 dark:border-gray-800`}>
|
||||
{isCollapsed ? (
|
||||
<img src="/logo.png" alt="Charon" className="h-12 w-auto" />
|
||||
) : (
|
||||
<img src="/banner.png" alt="Charon" className="h-14 w-auto max-w-[200px] object-contain" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 px-4 mt-16 lg:mt-6">
|
||||
<nav className="flex-1 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
if (item.children) {
|
||||
// Collapsible Group
|
||||
const isExpanded = expandedMenus.includes(item.name)
|
||||
const isActive = location.pathname.startsWith(item.path!)
|
||||
|
||||
// If sidebar is collapsed, render as a simple link (icon only)
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.path!}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors justify-center ${
|
||||
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'
|
||||
}`}
|
||||
title={item.name}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// If sidebar is expanded, render as collapsible accordion
|
||||
return (
|
||||
<div key={item.name} className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleMenu(item.name)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'text-blue-700 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="pl-11 space-y-1">
|
||||
{item.children.map((child: NavItem) => {
|
||||
// If this child has its own children, render a nested accordion
|
||||
if (child.children && child.children.length > 0) {
|
||||
|
||||
const nestedExpandedKey = `${item.name}:${child.name}`
|
||||
const isNestedOpen = expandedMenus.includes(nestedExpandedKey)
|
||||
return (
|
||||
<div key={child.path} className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleMenu(nestedExpandedKey)}
|
||||
className={`w-full flex items-center justify-between py-2 px-3 rounded-md text-sm transition-colors ${
|
||||
location.pathname.startsWith(child.path!)
|
||||
? 'text-blue-700 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{child.icon}</span>
|
||||
<span>{child.name}</span>
|
||||
</div>
|
||||
{isNestedOpen ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{isNestedOpen && (
|
||||
<div className="pl-6 space-y-1">
|
||||
{child.children.map((sub: NavItem) => (
|
||||
<Link
|
||||
key={sub.path}
|
||||
to={sub.path!}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
className={`block py-2 px-3 rounded-md text-sm transition-colors ${
|
||||
location.pathname === sub.path
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800/50'
|
||||
}`}
|
||||
>
|
||||
{sub.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const isChildActive = location.pathname === child.path
|
||||
return (
|
||||
<Link
|
||||
key={child.path}
|
||||
to={child.path!}
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
className={`block py-2 px-3 rounded-md text-sm transition-colors ${
|
||||
isChildActive
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800/50'
|
||||
}`}
|
||||
>
|
||||
{child.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isActive = location.pathname === item.path
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path!}
|
||||
onClick={() => setMobileSidebarOpen(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'
|
||||
} ${isCollapsed ? 'justify-center' : ''}`}
|
||||
title={isCollapsed ? item.name : ''}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
{!isCollapsed && item.name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className={`mt-2 border-t border-gray-200 dark:border-gray-800 pt-4 ${isCollapsed ? 'hidden' : ''}`}>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-500 text-center mb-2 flex flex-col gap-0.5">
|
||||
<span>Version {health?.version || 'dev'}</span>
|
||||
{health?.git_commit && health.git_commit !== 'unknown' && (
|
||||
<span className="text-[10px] opacity-75 font-mono">
|
||||
({health.git_commit.substring(0, 7)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileSidebarOpen(false)
|
||||
logout()
|
||||
}}
|
||||
className="mt-3 w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg text-sm font-medium transition-colors text-red-600 dark:text-red-400 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900"
|
||||
>
|
||||
<span className="text-lg">🚪</span>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collapsed Logout */}
|
||||
{isCollapsed && (
|
||||
<div className="mt-2 border-t border-gray-200 dark:border-gray-800 pt-4 pb-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileSidebarOpen(false)
|
||||
logout()
|
||||
}}
|
||||
className="w-full flex items-center justify-center p-3 rounded-lg transition-colors text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
title="Logout"
|
||||
>
|
||||
<span className="text-lg">🚪</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{/* Mobile Overlay */}
|
||||
{mobileSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-900/50 z-20 lg:hidden"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className={`flex-1 min-w-0 overflow-auto pt-16 lg:pt-0 flex flex-col transition-all duration-200 ${isCollapsed ? 'lg:ml-20' : 'lg:ml-64'}`}>
|
||||
{/* Desktop Header */}
|
||||
<header className="hidden lg:flex items-center justify-between px-8 h-20 bg-white dark:bg-dark-sidebar border-b border-gray-200 dark:border-gray-800 relative">
|
||||
<div className="w-1/3 flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-1/3 flex justify-center">
|
||||
{/* Banner moved to sidebar */}
|
||||
</div>
|
||||
<div className="w-1/3 flex justify-end items-center gap-4">
|
||||
{user && (
|
||||
<Link to="/settings/account" className="text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||
{user.name}
|
||||
</Link>
|
||||
)}
|
||||
<SystemStatus />
|
||||
<NotificationCenter />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4 lg:p-8 max-w-7xl mx-auto w-full">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { connectLiveLogs, LiveLogEntry, LiveLogFilter } from '../api/logs';
|
||||
import { Button } from './ui/Button';
|
||||
import { Pause, Play, Trash2, Filter } from 'lucide-react';
|
||||
|
||||
interface LiveLogViewerProps {
|
||||
filters?: LiveLogFilter;
|
||||
maxLogs?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LiveLogViewer({ filters = {}, maxLogs = 500, className = '' }: LiveLogViewerProps) {
|
||||
const [logs, setLogs] = useState<LiveLogEntry[]>([]);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [textFilter, setTextFilter] = useState('');
|
||||
const [levelFilter, setLevelFilter] = useState('');
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const closeConnectionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Auto-scroll when new logs arrive (only if not paused and user hasn't scrolled up)
|
||||
const shouldAutoScroll = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Connect to WebSocket
|
||||
const closeConnection = connectLiveLogs(
|
||||
filters,
|
||||
(log: LiveLogEntry) => {
|
||||
if (!isPaused) {
|
||||
setLogs((prev) => {
|
||||
const updated = [...prev, log];
|
||||
// Keep only last maxLogs entries
|
||||
if (updated.length > maxLogs) {
|
||||
return updated.slice(updated.length - maxLogs);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// onOpen callback - connection established
|
||||
console.log('Live log viewer connected');
|
||||
setIsConnected(true);
|
||||
},
|
||||
(error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
setIsConnected(false);
|
||||
},
|
||||
() => {
|
||||
console.log('Live log viewer disconnected');
|
||||
setIsConnected(false);
|
||||
}
|
||||
);
|
||||
|
||||
closeConnectionRef.current = closeConnection;
|
||||
// Don't set isConnected here - wait for onOpen callback
|
||||
|
||||
return () => {
|
||||
closeConnection();
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [filters, isPaused, maxLogs]);
|
||||
|
||||
// Handle auto-scroll
|
||||
useEffect(() => {
|
||||
if (shouldAutoScroll.current && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
// Track if user has manually scrolled
|
||||
const handleScroll = () => {
|
||||
if (logContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.current;
|
||||
// If scrolled to bottom (within 50px), enable auto-scroll
|
||||
shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 50;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
const handleTogglePause = () => {
|
||||
setIsPaused(!isPaused);
|
||||
};
|
||||
|
||||
// Filter logs based on text and level
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
if (textFilter && !log.message.toLowerCase().includes(textFilter.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (levelFilter && log.level.toLowerCase() !== levelFilter.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Color coding based on log level
|
||||
const getLevelColor = (level: string) => {
|
||||
const normalized = level.toLowerCase();
|
||||
if (normalized.includes('error') || normalized.includes('fatal')) return 'text-red-400';
|
||||
if (normalized.includes('warn')) return 'text-yellow-400';
|
||||
if (normalized.includes('info')) return 'text-blue-400';
|
||||
if (normalized.includes('debug')) return 'text-gray-400';
|
||||
return 'text-gray-300';
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-900 rounded-lg border border-gray-700 ${className}`}>
|
||||
{/* Header with controls */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">Live Security Logs</h3>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
isConnected ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'
|
||||
}`}
|
||||
>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleTogglePause}
|
||||
className="flex items-center gap-1"
|
||||
title={isPaused ? 'Resume' : 'Pause'}
|
||||
>
|
||||
{isPaused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="flex items-center gap-1"
|
||||
title="Clear logs"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 p-2 border-b border-gray-700 bg-gray-800">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by text..."
|
||||
value={textFilter}
|
||||
onChange={(e) => setTextFilter(e.target.value)}
|
||||
className="flex-1 px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<select
|
||||
value={levelFilter}
|
||||
onChange={(e) => setLevelFilter(e.target.value)}
|
||||
className="px-2 py-1 text-sm bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="debug">Debug</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="fatal">Fatal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Log display */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-96 overflow-y-auto p-3 font-mono text-xs bg-black"
|
||||
style={{ scrollBehavior: 'smooth' }}
|
||||
>
|
||||
{filteredLogs.length === 0 && (
|
||||
<div className="text-gray-500 text-center py-8">
|
||||
{logs.length === 0 ? 'No logs yet. Waiting for events...' : 'No logs match the current filters.'}
|
||||
</div>
|
||||
)}
|
||||
{filteredLogs.map((log, index) => (
|
||||
<div key={index} className="mb-1 hover:bg-gray-900 px-1 -mx-1 rounded">
|
||||
<span className="text-gray-500">{formatTimestamp(log.timestamp)}</span>
|
||||
<span className={`ml-2 font-semibold ${getLevelColor(log.level)}`}>{log.level.toUpperCase()}</span>
|
||||
{log.source && <span className="ml-2 text-purple-400">[{log.source}]</span>}
|
||||
<span className="ml-2 text-gray-200">{log.message}</span>
|
||||
{log.data && Object.keys(log.data).length > 0 && (
|
||||
<div className="ml-8 text-gray-400 text-xs">
|
||||
{JSON.stringify(log.data, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer with log count */}
|
||||
<div className="p-2 border-t border-gray-700 bg-gray-800 text-xs text-gray-400 flex items-center justify-between">
|
||||
<span>
|
||||
Showing {filteredLogs.length} of {logs.length} logs
|
||||
</span>
|
||||
{isPaused && <span className="text-yellow-400">⏸ Paused</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,331 +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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CharonLoader - Boat on Waves animation (Charon ferrying across the Styx)
|
||||
* Used for general proxy/configuration operations
|
||||
*/
|
||||
export function CharonLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-12 h-12',
|
||||
md: 'w-20 h-20',
|
||||
lg: 'w-28 h-28',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Loading">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full">
|
||||
{/* Water waves */}
|
||||
<path
|
||||
d="M0,60 Q10,55 20,60 T40,60 T60,60 T80,60 T100,60"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth="2"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
<path
|
||||
d="M0,65 Q10,60 20,65 T40,65 T60,65 T80,65 T100,65"
|
||||
fill="none"
|
||||
stroke="#60a5fa"
|
||||
strokeWidth="2"
|
||||
className="animate-pulse"
|
||||
style={{ animationDelay: '0.3s' }}
|
||||
/>
|
||||
<path
|
||||
d="M0,70 Q10,65 20,70 T40,70 T60,70 T80,70 T100,70"
|
||||
fill="none"
|
||||
stroke="#93c5fd"
|
||||
strokeWidth="2"
|
||||
className="animate-pulse"
|
||||
style={{ animationDelay: '0.6s' }}
|
||||
/>
|
||||
|
||||
{/* Boat (bobbing animation) */}
|
||||
<g className="animate-bob-boat" style={{ transformOrigin: '50% 50%' }}>
|
||||
{/* Hull */}
|
||||
<path
|
||||
d="M30,45 L30,50 Q35,55 50,55 T70,50 L70,45 Z"
|
||||
fill="#1e293b"
|
||||
stroke="#334155"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Deck */}
|
||||
<rect x="32" y="42" width="36" height="3" fill="#475569" />
|
||||
{/* Mast */}
|
||||
<line x1="50" y1="42" x2="50" y2="25" stroke="#94a3b8" strokeWidth="2" />
|
||||
{/* Sail */}
|
||||
<path
|
||||
d="M50,25 L65,30 L50,40 Z"
|
||||
fill="#e0e7ff"
|
||||
stroke="#818cf8"
|
||||
strokeWidth="1"
|
||||
className="animate-pulse-glow"
|
||||
/>
|
||||
{/* Charon silhouette */}
|
||||
<circle cx="45" cy="38" r="3" fill="#334155" />
|
||||
<rect x="44" y="41" width="2" height="4" fill="#334155" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CharonCoinLoader - Spinning Obol Coin animation (Payment to the Ferryman)
|
||||
* Used for authentication/login operations
|
||||
*/
|
||||
export function CharonCoinLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-12 h-12',
|
||||
md: 'w-20 h-20',
|
||||
lg: 'w-28 h-28',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Authenticating">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full">
|
||||
{/* Outer glow */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth="1"
|
||||
opacity="0.3"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="#fbbf24"
|
||||
strokeWidth="1"
|
||||
opacity="0.4"
|
||||
className="animate-pulse"
|
||||
style={{ animationDelay: '0.3s' }}
|
||||
/>
|
||||
|
||||
{/* Spinning coin */}
|
||||
<g className="animate-spin-y" style={{ transformOrigin: '50% 50%' }}>
|
||||
{/* Coin face */}
|
||||
<ellipse
|
||||
cx="50"
|
||||
cy="50"
|
||||
rx="30"
|
||||
ry="30"
|
||||
fill="url(#goldGradient)"
|
||||
stroke="#d97706"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Inner circle */}
|
||||
<ellipse
|
||||
cx="50"
|
||||
cy="50"
|
||||
rx="24"
|
||||
ry="24"
|
||||
fill="none"
|
||||
stroke="#92400e"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* Charon's boat symbol (simplified) */}
|
||||
<path
|
||||
d="M35,50 L40,45 L60,45 L65,50 L60,52 L40,52 Z"
|
||||
fill="#78350f"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<line x1="50" y1="45" x2="50" y2="38" stroke="#78350f" strokeWidth="2" />
|
||||
<path d="M50,38 L58,42 L50,46 Z" fill="#78350f" opacity="0.6" />
|
||||
</g>
|
||||
|
||||
{/* Gradient definition */}
|
||||
<defs>
|
||||
<radialGradient id="goldGradient">
|
||||
<stop offset="0%" stopColor="#fcd34d" />
|
||||
<stop offset="50%" stopColor="#f59e0b" />
|
||||
<stop offset="100%" stopColor="#d97706" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CerberusLoader - Three-Headed Guardian animation
|
||||
* Used for security operations (WAF, CrowdSec, ACL, Rate Limiting)
|
||||
*/
|
||||
export function CerberusLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-12 h-12',
|
||||
md: 'w-20 h-20',
|
||||
lg: 'w-28 h-28',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Security Loading">
|
||||
<svg viewBox="0 0 100 100" className="w-full h-full">
|
||||
{/* Shield background */}
|
||||
<path
|
||||
d="M50,10 L80,25 L80,50 Q80,75 50,90 Q20,75 20,50 L20,25 Z"
|
||||
fill="#7f1d1d"
|
||||
stroke="#991b1b"
|
||||
strokeWidth="2"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
|
||||
{/* Inner shield detail */}
|
||||
<path
|
||||
d="M50,15 L75,27 L75,50 Q75,72 50,85 Q25,72 25,50 L25,27 Z"
|
||||
fill="none"
|
||||
stroke="#dc2626"
|
||||
strokeWidth="1.5"
|
||||
opacity="0.6"
|
||||
/>
|
||||
|
||||
{/* Three heads (simplified circles with animation) */}
|
||||
{/* Left head */}
|
||||
<g className="animate-rotate-head" style={{ transformOrigin: '35% 45%' }}>
|
||||
<circle cx="35" cy="45" r="8" fill="#dc2626" stroke="#b91c1c" strokeWidth="1.5" />
|
||||
<circle cx="33" cy="43" r="1.5" fill="#fca5a5" />
|
||||
<circle cx="37" cy="43" r="1.5" fill="#fca5a5" />
|
||||
<path d="M32,48 Q35,50 38,48" stroke="#b91c1c" strokeWidth="1" fill="none" />
|
||||
</g>
|
||||
|
||||
{/* Center head (larger) */}
|
||||
<g className="animate-pulse-glow">
|
||||
<circle cx="50" cy="42" r="10" fill="#dc2626" stroke="#b91c1c" strokeWidth="1.5" />
|
||||
<circle cx="47" cy="40" r="1.5" fill="#fca5a5" />
|
||||
<circle cx="53" cy="40" r="1.5" fill="#fca5a5" />
|
||||
<path d="M46,47 Q50,50 54,47" stroke="#b91c1c" strokeWidth="1.5" fill="none" />
|
||||
</g>
|
||||
|
||||
{/* Right head */}
|
||||
<g className="animate-rotate-head" style={{ transformOrigin: '65% 45%', animationDelay: '0.5s' }}>
|
||||
<circle cx="65" cy="45" r="8" fill="#dc2626" stroke="#b91c1c" strokeWidth="1.5" />
|
||||
<circle cx="63" cy="43" r="1.5" fill="#fca5a5" />
|
||||
<circle cx="67" cy="43" r="1.5" fill="#fca5a5" />
|
||||
<path d="M62,48 Q65,50 68,48" stroke="#b91c1c" strokeWidth="1" fill="none" />
|
||||
</g>
|
||||
|
||||
{/* Body */}
|
||||
<ellipse cx="50" cy="65" rx="18" ry="12" fill="#7f1d1d" stroke="#991b1b" strokeWidth="1.5" />
|
||||
|
||||
{/* Paws */}
|
||||
<circle cx="40" cy="72" r="4" fill="#991b1b" />
|
||||
<circle cx="50" cy="72" r="4" fill="#991b1b" />
|
||||
<circle cx="60" cy="72" r="4" fill="#991b1b" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigReloadOverlay - Full-screen blocking overlay for Caddy configuration reloads
|
||||
*
|
||||
* Displays thematic loading animation based on operation type:
|
||||
* - 'charon' (blue): Proxy hosts, certificates, general config operations
|
||||
* - 'coin' (gold): Authentication/login operations
|
||||
* - 'cerberus' (red): Security operations (WAF, CrowdSec, ACL, Rate Limiting)
|
||||
*
|
||||
* @param message - Primary message (e.g., "Ferrying new host...")
|
||||
* @param submessage - Secondary context (e.g., "Charon is crossing the Styx")
|
||||
* @param type - Theme variant: 'charon', 'coin', or 'cerberus'
|
||||
*/
|
||||
export function ConfigReloadOverlay({
|
||||
message = 'Ferrying configuration...',
|
||||
submessage = 'Charon is crossing the Styx',
|
||||
type = 'charon',
|
||||
}: {
|
||||
message?: string
|
||||
submessage?: string
|
||||
type?: 'charon' | 'coin' | 'cerberus'
|
||||
}) {
|
||||
const Loader =
|
||||
type === 'cerberus' ? CerberusLoader :
|
||||
type === 'coin' ? CharonCoinLoader :
|
||||
CharonLoader
|
||||
|
||||
const bgColor =
|
||||
type === 'cerberus' ? 'bg-red-950/90' :
|
||||
type === 'coin' ? 'bg-amber-950/90' :
|
||||
'bg-blue-950/90'
|
||||
|
||||
const borderColor =
|
||||
type === 'cerberus' ? 'border-red-900/50' :
|
||||
type === 'coin' ? 'border-amber-900/50' :
|
||||
'border-blue-900/50'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-900/70 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className={`${bgColor} ${borderColor} border-2 rounded-lg p-8 flex flex-col items-center gap-4 shadow-2xl max-w-md mx-4`}>
|
||||
<Loader size="lg" />
|
||||
<div className="text-center">
|
||||
<p className="text-slate-100 text-lg font-semibold mb-1">{message}</p>
|
||||
<p className="text-slate-300 text-sm">{submessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,112 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Search, Download, RefreshCw } from 'lucide-react';
|
||||
import { Button } from './ui/Button';
|
||||
|
||||
interface LogFiltersProps {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
status: string;
|
||||
onStatusChange: (value: string) => void;
|
||||
level: string;
|
||||
onLevelChange: (value: string) => void;
|
||||
host: string;
|
||||
onHostChange: (value: string) => void;
|
||||
sort: 'asc' | 'desc';
|
||||
onSortChange: (value: 'asc' | 'desc') => void;
|
||||
onRefresh: () => void;
|
||||
onDownload: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const LogFilters: React.FC<LogFiltersProps> = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
status,
|
||||
onStatusChange,
|
||||
level,
|
||||
onLevelChange,
|
||||
host,
|
||||
onHostChange,
|
||||
sort,
|
||||
onSortChange,
|
||||
onRefresh,
|
||||
onDownload,
|
||||
isLoading
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row gap-4 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="block w-full pl-10 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-48">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by Host"
|
||||
value={host}
|
||||
onChange={(e) => onHostChange(e.target.value)}
|
||||
className="block w-full 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-32">
|
||||
<select
|
||||
value={level}
|
||||
onChange={(e) => onLevelChange(e.target.value)}
|
||||
className="block w-full 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"
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="DEBUG">Debug</option>
|
||||
<option value="INFO">Info</option>
|
||||
<option value="WARN">Warn</option>
|
||||
<option value="ERROR">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-32">
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
className="block w-full 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"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="2xx">2xx Success</option>
|
||||
<option value="3xx">3xx Redirect</option>
|
||||
<option value="4xx">4xx Client Error</option>
|
||||
<option value="5xx">5xx Server Error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-32">
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => onSortChange(e.target.value as 'asc' | 'desc')}
|
||||
className="block w-full 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"
|
||||
>
|
||||
<option value="desc">Newest First</option>
|
||||
<option value="asc">Oldest First</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onRefresh} variant="secondary" size="sm" isLoading={isLoading}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={onDownload} variant="secondary" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from 'react';
|
||||
import { CaddyAccessLog } from '../api/logs';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface LogTableProps {
|
||||
logs: CaddyAccessLog[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const LogTable: React.FC<LogTableProps> = ({ logs, isLoading }) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full h-64 flex items-center justify-center text-gray-500">
|
||||
Loading logs...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-64 flex items-center justify-center text-gray-500">
|
||||
No logs found matching criteria.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Time</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Method</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Host</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Path</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">IP</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Latency</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{logs.map((log, idx) => {
|
||||
// Check if this is a structured access log or a plain text system log
|
||||
const isAccessLog = log.status > 0 || (log.request && log.request.method);
|
||||
|
||||
if (!isAccessLog) {
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(log.ts * 1000), 'MMM d HH:mm:ss')}
|
||||
</td>
|
||||
<td colSpan={7} className="px-6 py-4 text-sm text-gray-900 dark:text-white font-mono whitespace-pre-wrap break-all">
|
||||
{log.msg}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(log.ts * 1000), 'MMM d HH:mm:ss')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{log.status > 0 && (
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${log.status >= 500 ? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' :
|
||||
log.status >= 400 ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' :
|
||||
log.status >= 300 ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
|
||||
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||
{log.request?.method}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{log.request?.host}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate" title={log.request?.uri}>
|
||||
{log.request?.uri}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{log.request?.remote_ip}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{log.duration > 0 ? (log.duration * 1000).toFixed(2) + 'ms' : ''}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400 max-w-xs truncate" title={log.msg}>
|
||||
{log.msg}
|
||||
</td>
|
||||
</tr>
|
||||
)})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,156 +0,0 @@
|
||||
import { useState, type FC } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, X, Info, AlertTriangle, AlertCircle, CheckCircle, ExternalLink } from 'lucide-react';
|
||||
import { getNotifications, markNotificationRead, markAllNotificationsRead, checkUpdates } from '../api/system';
|
||||
|
||||
const NotificationCenter: FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: notifications = [] } = useQuery({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: () => getNotifications(true),
|
||||
refetchInterval: 30000, // Poll every 30s
|
||||
});
|
||||
|
||||
const { data: updateInfo } = useQuery({
|
||||
queryKey: ['system-updates'],
|
||||
queryFn: checkUpdates,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
});
|
||||
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: markNotificationRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: markAllNotificationsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
},
|
||||
});
|
||||
|
||||
const unreadCount = notifications.length + (updateInfo?.available ? 1 : 0);
|
||||
const hasCritical = notifications.some(n => n.type === 'error');
|
||||
const hasWarning = notifications.some(n => n.type === 'warning') || updateInfo?.available;
|
||||
|
||||
const getBellColor = () => {
|
||||
if (hasCritical) return 'text-red-500 hover:text-red-600';
|
||||
if (hasWarning) return 'text-yellow-500 hover:text-yellow-600';
|
||||
return 'text-green-500 hover:text-green-600';
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'success': return <CheckCircle className="w-5 h-5 text-green-500" />;
|
||||
case 'warning': return <AlertTriangle className="w-5 h-5 text-yellow-500" />;
|
||||
case 'error': return <AlertCircle className="w-5 h-5 text-red-500" />;
|
||||
default: return <Info className="w-5 h-5 text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`relative p-2 focus:outline-none transition-colors ${getBellColor()}`}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className="w-6 h-6" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/4 -translate-y-1/4 bg-red-600 rounded-full">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
data-testid="notification-backdrop"
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setIsOpen(false)}
|
||||
></div>
|
||||
<div className="absolute right-0 z-20 w-80 mt-2 overflow-hidden bg-white rounded-md shadow-lg dark:bg-gray-800 ring-1 ring-black ring-opacity-5">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Notifications</h3>
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
className="text-xs text-blue-600 hover:text-blue-500 dark:text-blue-400"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{/* Update Notification */}
|
||||
{updateInfo?.available && (
|
||||
<div className="flex items-start px-4 py-3 border-b dark:border-gray-700 bg-yellow-50 dark:bg-yellow-900/10 hover:bg-yellow-100 dark:hover:bg-yellow-900/20">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-500" />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Update Available: {updateInfo.latest_version}
|
||||
</p>
|
||||
<a
|
||||
href={updateInfo.changelog_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 flex items-center"
|
||||
>
|
||||
View Changelog <ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifications.length === 0 && !updateInfo?.available ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No new notifications
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className="flex items-start px-4 py-3 border-b dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getIcon(notification.type)}
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{new Date(notification.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0 flex">
|
||||
<button
|
||||
onClick={() => markReadMutation.mutate(notification.id)}
|
||||
className="bg-white dark:bg-gray-800 rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationCenter;
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from 'react';
|
||||
import { calculatePasswordStrength } from '../utils/passwordStrength';
|
||||
|
||||
interface Props {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const PasswordStrengthMeter: React.FC<Props> = ({ password }) => {
|
||||
const { score, label, color, feedback } = calculatePasswordStrength(password);
|
||||
|
||||
// Calculate width percentage based on score (0-4)
|
||||
// 0: 5%, 1: 25%, 2: 50%, 3: 75%, 4: 100%
|
||||
const width = Math.max(5, (score / 4) * 100);
|
||||
|
||||
// Map color name to Tailwind classes
|
||||
const getColorClass = (c: string) => {
|
||||
switch (c) {
|
||||
case 'red': return 'bg-red-500';
|
||||
case 'yellow': return 'bg-yellow-500';
|
||||
case 'green': return 'bg-green-500';
|
||||
default: return 'bg-gray-300';
|
||||
}
|
||||
};
|
||||
|
||||
const getTextColorClass = (c: string) => {
|
||||
switch (c) {
|
||||
case 'red': return 'text-red-500';
|
||||
case 'yellow': return 'text-yellow-600';
|
||||
case 'green': return 'text-green-600';
|
||||
default: return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
if (!password) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className={`font-medium ${getTextColorClass(color)}`}>
|
||||
{label}
|
||||
</span>
|
||||
{feedback.length > 0 && (
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{feedback[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-1.5 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ease-out ${getColorClass(color)}`}
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,205 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, Check, X, CircleHelp } from 'lucide-react'
|
||||
import { type RemoteServer, testCustomRemoteServerConnection } from '../api/remoteServers'
|
||||
|
||||
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)
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle')
|
||||
|
||||
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 (!formData.host || !formData.port) return
|
||||
setTestStatus('testing')
|
||||
setError(null)
|
||||
try {
|
||||
const result = await testCustomRemoteServerConnection(formData.host, formData.port)
|
||||
if (result.reachable) {
|
||||
setTestStatus('success')
|
||||
setTimeout(() => setTestStatus('idle'), 3000)
|
||||
} else {
|
||||
setTestStatus('error')
|
||||
setError(`Connection failed: ${result.error || 'Unknown error'}`)
|
||||
}
|
||||
} catch {
|
||||
setTestStatus('error')
|
||||
setError('Connection failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/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 => {
|
||||
const newProvider = e.target.value;
|
||||
setFormData({
|
||||
...formData,
|
||||
provider: newProvider,
|
||||
// Set default port for Docker
|
||||
port: newProvider === 'docker' ? 2375 : (newProvider === 'generic' ? 22 : formData.port)
|
||||
})
|
||||
}}
|
||||
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 => {
|
||||
const v = parseInt(e.target.value)
|
||||
setFormData({ ...formData, port: Number.isNaN(v) ? 0 : v })
|
||||
}}
|
||||
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>
|
||||
{formData.provider !== 'docker' && (
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testStatus === 'testing' || !formData.host || !formData.port}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 mr-auto ${
|
||||
testStatus === 'success' ? 'bg-green-600 text-white' :
|
||||
testStatus === 'error' ? 'bg-red-600 text-white' :
|
||||
'bg-gray-700 hover:bg-gray-600 text-white'
|
||||
}`}
|
||||
>
|
||||
{testStatus === 'testing' ? <Loader2 className="w-4 h-4 animate-spin" /> :
|
||||
testStatus === 'success' ? <Check className="w-4 h-4" /> :
|
||||
testStatus === 'error' ? <X className="w-4 h-4" /> :
|
||||
<CircleHelp className="w-4 h-4" />}
|
||||
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,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { LoadingOverlay } from './LoadingStates';
|
||||
|
||||
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingOverlay message="Authenticating..." />; // Consistent loading UX
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default RequireAuth;
|
||||
@@ -1,233 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from './ui/Button';
|
||||
import { Switch } from './ui/Switch';
|
||||
import {
|
||||
useSecurityNotificationSettings,
|
||||
useUpdateSecurityNotificationSettings,
|
||||
} from '../hooks/useNotifications';
|
||||
|
||||
interface SecurityNotificationSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SecurityNotificationSettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: SecurityNotificationSettingsModalProps) {
|
||||
const { data: settings, isLoading } = useSecurityNotificationSettings();
|
||||
const updateMutation = useUpdateSecurityNotificationSettings();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
enabled: false,
|
||||
min_log_level: 'warn',
|
||||
notify_waf_blocks: true,
|
||||
notify_acl_denials: true,
|
||||
notify_rate_limit_hits: true,
|
||||
webhook_url: '',
|
||||
email_recipients: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setFormData({
|
||||
enabled: settings.enabled,
|
||||
min_log_level: settings.min_log_level,
|
||||
notify_waf_blocks: settings.notify_waf_blocks,
|
||||
notify_acl_denials: settings.notify_acl_denials,
|
||||
notify_rate_limit_hits: settings.notify_rate_limit_hits,
|
||||
webhook_url: settings.webhook_url || '',
|
||||
email_recipients: settings.email_recipients || '',
|
||||
});
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate(formData, {
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-white">Security Notification Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{isLoading && (
|
||||
<div className="text-center text-gray-400">Loading settings...</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<>
|
||||
{/* Master Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="enable-notifications" className="text-sm font-medium text-white">Enable Notifications</label>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Receive alerts when security events occur
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-notifications"
|
||||
checked={formData.enabled}
|
||||
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Minimum Log Level */}
|
||||
<div>
|
||||
<label htmlFor="min-log-level" className="block text-sm font-medium text-white mb-2">
|
||||
Minimum Log Level
|
||||
</label>
|
||||
<select
|
||||
id="min-log-level"
|
||||
value={formData.min_log_level}
|
||||
onChange={(e) => setFormData({ ...formData, min_log_level: e.target.value })}
|
||||
disabled={!formData.enabled}
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="debug">Debug (All logs)</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="fatal">Fatal (Critical only)</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Only logs at this level or higher will trigger notifications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Event Type Filters */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-white">Notify On:</h3>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="notify-waf" className="text-sm text-white">WAF Blocks</label>
|
||||
<p className="text-xs text-gray-400">
|
||||
When the Web Application Firewall blocks a request
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-waf"
|
||||
checked={formData.notify_waf_blocks}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, notify_waf_blocks: e.target.checked })
|
||||
}
|
||||
disabled={!formData.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="notify-acl" className="text-sm text-white">ACL Denials</label>
|
||||
<p className="text-xs text-gray-400">
|
||||
When an IP is denied by Access Control Lists
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-acl"
|
||||
checked={formData.notify_acl_denials}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, notify_acl_denials: e.target.checked })
|
||||
}
|
||||
disabled={!formData.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label htmlFor="notify-rate-limit" className="text-sm text-white">Rate Limit Hits</label>
|
||||
<p className="text-xs text-gray-400">
|
||||
When a client exceeds rate limiting thresholds
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notify-rate-limit"
|
||||
checked={formData.notify_rate_limit_hits}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, notify_rate_limit_hits: e.target.checked })
|
||||
}
|
||||
disabled={!formData.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Webhook URL (optional, for future use) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Webhook URL (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.webhook_url}
|
||||
onChange={(e) => setFormData({ ...formData, webhook_url: e.target.value })}
|
||||
placeholder="https://your-webhook-endpoint.com/alert"
|
||||
disabled={!formData.enabled}
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
POST requests will be sent to this URL when events occur
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email Recipients (optional, for future use) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Email Recipients (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.email_recipients}
|
||||
onChange={(e) => setFormData({ ...formData, email_recipients: e.target.value })}
|
||||
placeholder="admin@example.com, security@example.com"
|
||||
disabled={!formData.enabled}
|
||||
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white placeholder-gray-400 focus:outline-none focus:border-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Comma-separated email addresses
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-700">
|
||||
<Button variant="secondary" onClick={onClose} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={updateMutation.isPending}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,17 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { checkUpdates } from '../api/system';
|
||||
|
||||
const SystemStatus: React.FC = () => {
|
||||
// We still query for updates here to keep the cache fresh,
|
||||
// but the UI is now handled by NotificationCenter
|
||||
useQuery({
|
||||
queryKey: ['system-updates'],
|
||||
queryFn: checkUpdates,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default SystemStatus;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
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,57 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toastCallbacks, Toast } from '../utils/toast'
|
||||
|
||||
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,105 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Activity, CheckCircle2, XCircle, AlertCircle } from 'lucide-react'
|
||||
import { getMonitors } from '../api/uptime'
|
||||
|
||||
export default function UptimeWidget() {
|
||||
const { data: monitors, isLoading } = useQuery({
|
||||
queryKey: ['monitors'],
|
||||
queryFn: getMonitors,
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
const upCount = monitors?.filter(m => m.status === 'up').length || 0
|
||||
const downCount = monitors?.filter(m => m.status === 'down').length || 0
|
||||
const totalCount = monitors?.length || 0
|
||||
|
||||
const allUp = totalCount > 0 && downCount === 0
|
||||
const hasDown = downCount > 0
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/uptime"
|
||||
className="bg-dark-card p-6 rounded-lg border border-gray-800 hover:border-gray-700 transition-colors block"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-400">Uptime Status</span>
|
||||
</div>
|
||||
{hasDown && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-900/30 text-red-400 rounded-full animate-pulse">
|
||||
Issues
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-gray-500 text-sm">Loading...</div>
|
||||
) : totalCount === 0 ? (
|
||||
<div className="text-gray-500 text-sm">No monitors configured</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{allUp ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-6 h-6 text-green-400" />
|
||||
<span className="text-lg font-bold text-green-400">All Systems Operational</span>
|
||||
</>
|
||||
) : hasDown ? (
|
||||
<>
|
||||
<XCircle className="w-6 h-6 text-red-400" />
|
||||
<span className="text-lg font-bold text-red-400">
|
||||
{downCount} {downCount === 1 ? 'Site' : 'Sites'} Down
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="w-6 h-6 text-yellow-400" />
|
||||
<span className="text-lg font-bold text-yellow-400">Unknown Status</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="flex gap-4 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-green-400"></span>
|
||||
<span className="text-gray-400">{upCount} up</span>
|
||||
</div>
|
||||
{downCount > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-red-400"></span>
|
||||
<span className="text-gray-400">{downCount} down</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-gray-500">
|
||||
{totalCount} total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mini status bars */}
|
||||
{monitors && monitors.length > 0 && (
|
||||
<div className="flex gap-1 mt-3">
|
||||
{monitors.slice(0, 20).map((monitor) => (
|
||||
<div
|
||||
key={monitor.id}
|
||||
className={`flex-1 h-2 rounded-sm ${
|
||||
monitor.status === 'up' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
title={`${monitor.name}: ${monitor.status.toUpperCase()}`}
|
||||
/>
|
||||
))}
|
||||
{monitors.length > 20 && (
|
||||
<div className="text-xs text-gray-500 ml-1">+{monitors.length - 20}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500 mt-3">Click for detailed view →</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import AccessListSelector from '../AccessListSelector';
|
||||
import * as useAccessListsHook from '../../hooks/useAccessLists';
|
||||
import type { AccessList } from '../../api/accessLists';
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../hooks/useAccessLists');
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AccessListSelector', () => {
|
||||
it('should render with no access lists', () => {
|
||||
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
|
||||
data: [],
|
||||
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
|
||||
|
||||
const mockOnChange = vi.fn();
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AccessListSelector value={null} onChange={mockOnChange} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.getByText('No Access Control (Public)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with access lists and show only enabled ones', () => {
|
||||
const mockLists: AccessList[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'Test ACL 1',
|
||||
description: 'Description 1',
|
||||
type: 'whitelist',
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: 'uuid-2',
|
||||
name: 'Test ACL 2',
|
||||
description: 'Description 2',
|
||||
type: 'blacklist',
|
||||
ip_rules: '[]',
|
||||
country_codes: '',
|
||||
local_network_only: false,
|
||||
enabled: false,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
|
||||
data: mockLists,
|
||||
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
|
||||
|
||||
const mockOnChange = vi.fn();
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AccessListSelector value={null} onChange={mockOnChange} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test ACL 1 (whitelist)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Test ACL 2 (blacklist)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show selected ACL details', () => {
|
||||
const mockLists: AccessList[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: 'uuid-1',
|
||||
name: 'Selected ACL',
|
||||
description: 'This is selected',
|
||||
type: 'geo_whitelist',
|
||||
ip_rules: '[]',
|
||||
country_codes: 'US,CA',
|
||||
local_network_only: false,
|
||||
enabled: true,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(useAccessListsHook.useAccessLists).mockReturnValue({
|
||||
data: mockLists,
|
||||
} as unknown as ReturnType<typeof useAccessListsHook.useAccessLists>);
|
||||
|
||||
const mockOnChange = vi.fn();
|
||||
const Wrapper = createWrapper();
|
||||
|
||||
render(
|
||||
<Wrapper>
|
||||
<AccessListSelector value={1} onChange={mockOnChange} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Selected ACL')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is selected')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Countries: US,CA/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import CertificateList from '../CertificateList'
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient'
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({
|
||||
certificates: [
|
||||
{ id: 1, name: 'CustomCert', domain: 'example.com', issuer: 'Custom CA', expires_at: new Date().toISOString(), status: 'expired', provider: 'custom' },
|
||||
{ id: 2, name: 'LE Staging', domain: 'staging.example.com', issuer: "Let's Encrypt Staging", expires_at: new Date().toISOString(), status: 'untrusted', provider: 'letsencrypt-staging' },
|
||||
{ id: 3, name: 'ActiveCert', domain: 'active.example.com', issuer: 'Custom CA', expires_at: new Date().toISOString(), status: 'valid', provider: 'custom' },
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('../../api/certificates', () => ({
|
||||
deleteCertificate: vi.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/backups', () => ({
|
||||
createBackup: vi.fn(async () => ({ filename: 'backup-cert' })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useProxyHosts', () => ({
|
||||
useProxyHosts: vi.fn(() => ({
|
||||
hosts: [
|
||||
{ uuid: 'h1', name: 'Host1', certificate_id: 3 },
|
||||
],
|
||||
loading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
createHost: vi.fn(),
|
||||
updateHost: vi.fn(),
|
||||
deleteHost: vi.fn(),
|
||||
bulkUpdateACL: vi.fn(),
|
||||
isBulkUpdating: false,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}))
|
||||
|
||||
function renderWithClient(ui: React.ReactNode) {
|
||||
const qc = createTestQueryClient()
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('CertificateList', () => {
|
||||
it('deletes custom certificate when confirmed', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const { createBackup } = await import('../../api/backups')
|
||||
const { toast } = await import('../../utils/toast')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const rows = await screen.findAllByRole('row')
|
||||
const customRow = rows.find(r => r.querySelector('td')?.textContent?.includes('CustomCert')) as HTMLElement
|
||||
expect(customRow).toBeTruthy()
|
||||
const customBtn = customRow.querySelector('button[title="Delete Certificate"]') as HTMLButtonElement
|
||||
expect(customBtn).toBeTruthy()
|
||||
await user.click(customBtn)
|
||||
|
||||
await waitFor(() => expect(createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(1))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Certificate deleted'))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('deletes staging certificate when confirmed', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockImplementation(() => true)
|
||||
const { deleteCertificate } = await import('../../api/certificates')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderWithClient(<CertificateList />)
|
||||
const stagingButtons = await screen.findAllByTitle('Delete Staging Certificate')
|
||||
expect(stagingButtons.length).toBeGreaterThan(0)
|
||||
await user.click(stagingButtons[0])
|
||||
|
||||
await waitFor(() => expect(deleteCertificate).toHaveBeenCalledWith(2))
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('blocks deletion when certificate is in use by a proxy host', async () => {
|
||||
const { toast } = await import('../../utils/toast')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const deleteButtons = await screen.findAllByTitle('Delete Certificate')
|
||||
// Find button corresponding to ActiveCert (id 3)
|
||||
const activeButton = deleteButtons.find(btn => btn.closest('tr')?.querySelector('td')?.textContent?.includes('ActiveCert'))
|
||||
expect(activeButton).toBeTruthy()
|
||||
if (activeButton) await user.click(activeButton)
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('in use')))
|
||||
})
|
||||
|
||||
it('blocks deletion when certificate status is active (valid/expiring)', async () => {
|
||||
const { toast } = await import('../../utils/toast')
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<CertificateList />)
|
||||
const deleteButtons = await screen.findAllByTitle('Delete Certificate')
|
||||
// ActiveCert (valid) should block even if not linked – ensure hosts mock links it so previous test covers linkage.
|
||||
// Here, simulate clicking a valid cert button if present
|
||||
const validButton = deleteButtons.find(btn => btn.closest('tr')?.querySelector('td')?.textContent?.includes('ActiveCert'))
|
||||
expect(validButton).toBeTruthy()
|
||||
if (validButton) await user.click(validButton)
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
@@ -1,262 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
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={[]}
|
||||
conflictDetails={{}}
|
||||
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}
|
||||
conflictDetails={{}}
|
||||
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={[]}
|
||||
conflictDetails={{}}
|
||||
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 and names', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
const dropdown = screen.getByRole('combobox') as HTMLSelectElement
|
||||
await userEvent.selectOptions(dropdown, 'overwrite')
|
||||
|
||||
const commitButton = screen.getByText('Commit Import')
|
||||
await userEvent.click(commitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCommit).toHaveBeenCalledWith(
|
||||
{ 'test.example.com': 'overwrite' },
|
||||
{ 'test.example.com': 'test.example.com' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={[]}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Back'))
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows conflict indicator on conflicting hosts', () => {
|
||||
const conflicts = ['test.example.com']
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={{}}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
expect(screen.queryByText('No conflict')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands and collapses conflict details', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
const conflictDetails = {
|
||||
'test.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
websocket: true,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.2',
|
||||
forward_port: 9090,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
// Initially collapsed
|
||||
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
|
||||
|
||||
// Find and click expand button (it's the ▶ button)
|
||||
const expandButton = screen.getByText('▶')
|
||||
await userEvent.click(expandButton)
|
||||
|
||||
// Now should show details
|
||||
expect(screen.getByText('Current Configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText('Imported Configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText('http://192.168.1.1:8080')).toBeInTheDocument()
|
||||
expect(screen.getByText('http://192.168.1.2:9090')).toBeInTheDocument()
|
||||
|
||||
// Click collapse button
|
||||
const collapseButton = screen.getByText('▼')
|
||||
await userEvent.click(collapseButton)
|
||||
|
||||
// Details should be hidden again
|
||||
expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows recommendation based on configuration differences', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
const conflictDetails = {
|
||||
'test.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
websocket: false,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
// Expand to see recommendation
|
||||
const expandButton = screen.getByText('▶')
|
||||
await userEvent.click(expandButton)
|
||||
|
||||
// Should show recommendation about config changes (SSL differs)
|
||||
expect(screen.getByText(/different SSL or WebSocket settings/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('highlights configuration differences', async () => {
|
||||
const conflicts = ['test.example.com']
|
||||
const conflictDetails = {
|
||||
'test.example.com': {
|
||||
existing: {
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.1',
|
||||
forward_port: 8080,
|
||||
ssl_forced: true,
|
||||
websocket: true,
|
||||
enabled: true,
|
||||
},
|
||||
imported: {
|
||||
forward_scheme: 'https',
|
||||
forward_host: '192.168.1.2',
|
||||
forward_port: 9090,
|
||||
ssl_forced: false,
|
||||
websocket: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<ImportReviewTable
|
||||
hosts={mockImportPreview.hosts}
|
||||
conflicts={conflicts}
|
||||
conflictDetails={conflictDetails}
|
||||
errors={[]}
|
||||
onCommit={mockOnCommit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
const expandButton = screen.getByText('▶')
|
||||
await userEvent.click(expandButton)
|
||||
|
||||
// Check for differences being displayed
|
||||
expect(screen.getByText('https://192.168.1.2:9090')).toBeInTheDocument()
|
||||
expect(screen.getByText('http://192.168.1.1:8080')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,320 +0,0 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Layout from '../Layout'
|
||||
import { ThemeProvider } from '../../context/ThemeContext'
|
||||
import * as featureFlagsApi from '../../api/featureFlags'
|
||||
|
||||
const mockLogout = vi.fn()
|
||||
|
||||
// Mock AuthContext
|
||||
vi.mock('../../hooks/useAuth', () => ({
|
||||
useAuth: () => ({
|
||||
logout: mockLogout,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API
|
||||
vi.mock('../../api/health', () => ({
|
||||
checkHealth: vi.fn().mockResolvedValue({
|
||||
version: '0.1.0',
|
||||
git_commit: 'abcdef1',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/featureFlags', () => ({
|
||||
getFeatureFlags: vi.fn().mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const renderWithProviders = (children: ReactNode) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
localStorage.setItem('sidebarCollapsed', 'false')
|
||||
// Default: all features enabled
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the application logo', () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
const logos = screen.getAllByAltText('Charon')
|
||||
expect(logos.length).toBeGreaterThan(0)
|
||||
expect(logos[0]).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all navigation items', async () => {
|
||||
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()
|
||||
// Expand Tasks and Import to see nested items
|
||||
await userEvent.click(screen.getByText('Tasks'))
|
||||
expect(screen.getByText('Import')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByText('Import'))
|
||||
expect(screen.getByText('Caddyfile')).toBeInTheDocument()
|
||||
expect(screen.getByText('CrowdSec')).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', async () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Version 0.1.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls logout when logout button is clicked', async () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Logout'))
|
||||
|
||||
expect(mockLogout).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles sidebar on mobile', async () => {
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
// The mobile sidebar toggle is found by test-id
|
||||
const toggleButton = screen.getByTestId('mobile-menu-toggle')
|
||||
|
||||
// Click to open the sidebar
|
||||
await userEvent.click(toggleButton)
|
||||
|
||||
// The overlay should be present when mobile sidebar is open
|
||||
// The overlay has class 'fixed inset-0 bg-gray-900/50 z-20 lg:hidden'
|
||||
// Click the toggle again to close
|
||||
await userEvent.click(toggleButton)
|
||||
|
||||
// Toggle button should still be in the document
|
||||
expect(toggleButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('persists collapse state to localStorage', async () => {
|
||||
localStorage.clear()
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
const collapseBtn = await screen.findByTitle('Collapse sidebar')
|
||||
await userEvent.click(collapseBtn)
|
||||
expect(JSON.parse(localStorage.getItem('sidebarCollapsed') || 'false')).toBe(true)
|
||||
})
|
||||
|
||||
it('restores collapsed state from localStorage on load', async () => {
|
||||
localStorage.setItem('sidebarCollapsed', 'true')
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
expect(await screen.findByTitle('Expand sidebar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Feature Flags - Conditional Sidebar Items', () => {
|
||||
it('displays Cerberus nav item when Cerberus is enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides Cerberus nav item when Cerberus is disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Cerberus')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays Uptime nav item when Uptime is enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides Uptime nav item when Uptime is disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Uptime')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Cerberus and Uptime when both features are enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides both Cerberus and Uptime when both features are disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Cerberus')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Uptime')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults to showing Cerberus and Uptime when feature flags are loading', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({} as any)
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
// When flags are undefined, items should be visible by default (conservative approach)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows other nav items regardless of feature flags', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(
|
||||
<Layout>
|
||||
<div>Test Content</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Dashboard')).toBeInTheDocument()
|
||||
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
|
||||
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
|
||||
expect(screen.getByText('Certificates')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,315 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { LiveLogViewer } from '../LiveLogViewer';
|
||||
import * as logsApi from '../../api/logs';
|
||||
|
||||
// Mock the connectLiveLogs function
|
||||
vi.mock('../../api/logs', async () => {
|
||||
const actual = await vi.importActual('../../api/logs');
|
||||
return {
|
||||
...actual,
|
||||
connectLiveLogs: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('LiveLogViewer', () => {
|
||||
let mockCloseConnection: ReturnType<typeof vi.fn>;
|
||||
let mockOnMessage: ((log: logsApi.LiveLogEntry) => void) | null;
|
||||
let mockOnClose: (() => void) | null;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCloseConnection = vi.fn();
|
||||
mockOnMessage = null;
|
||||
mockOnClose = null;
|
||||
|
||||
vi.mocked(logsApi.connectLiveLogs).mockImplementation((_filters, onMessage, onOpen, _onError, onClose) => {
|
||||
mockOnMessage = onMessage;
|
||||
mockOnClose = onClose ?? null;
|
||||
// Simulate connection success
|
||||
if (onOpen) {
|
||||
setTimeout(() => onOpen(), 0);
|
||||
}
|
||||
return mockCloseConnection as () => void;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the component with initial state', async () => {
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
expect(screen.getByText('Live Security Logs')).toBeTruthy();
|
||||
// Initially disconnected until WebSocket opens
|
||||
expect(screen.getByText('Disconnected')).toBeTruthy();
|
||||
|
||||
// Wait for onOpen callback to be called
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connected')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('displays incoming log messages', async () => {
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Simulate receiving a log
|
||||
const logEntry: logsApi.LiveLogEntry = {
|
||||
level: 'info',
|
||||
timestamp: '2025-12-09T10:30:00Z',
|
||||
message: 'Test log message',
|
||||
source: 'test',
|
||||
};
|
||||
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage(logEntry);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test log message')).toBeTruthy();
|
||||
expect(screen.getByText('INFO')).toBeTruthy();
|
||||
expect(screen.getByText('[test]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters logs by text', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Add multiple logs
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'First message' });
|
||||
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Second message' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('First message')).toBeTruthy();
|
||||
expect(screen.getByText('Second message')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Apply text filter
|
||||
const filterInput = screen.getByPlaceholderText('Filter by text...');
|
||||
await user.type(filterInput, 'First');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('First message')).toBeTruthy();
|
||||
expect(screen.queryByText('Second message')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters logs by level', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Add multiple logs
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Info message' });
|
||||
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Error message' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Info message')).toBeTruthy();
|
||||
expect(screen.getByText('Error message')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Apply level filter
|
||||
const levelSelect = screen.getByRole('combobox');
|
||||
await user.selectOptions(levelSelect, 'error');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Info message')).toBeFalsy();
|
||||
expect(screen.getByText('Error message')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('pauses and resumes log streaming', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Add initial log
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Before pause' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Before pause')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click pause button
|
||||
const pauseButton = screen.getByTitle('Pause');
|
||||
await user.click(pauseButton);
|
||||
|
||||
// Verify paused state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('⏸ Paused')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Try to add log while paused (should not appear)
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'During pause' });
|
||||
}
|
||||
|
||||
// Log should not appear
|
||||
expect(screen.queryByText('During pause')).toBeFalsy();
|
||||
|
||||
// Resume
|
||||
const resumeButton = screen.getByTitle('Resume');
|
||||
await user.click(resumeButton);
|
||||
|
||||
// Add log after resume
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:02Z', message: 'After resume' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('After resume')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears all logs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Add logs
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Log 1' });
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'Log 2' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Log 1')).toBeTruthy();
|
||||
expect(screen.getByText('Log 2')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click clear button
|
||||
const clearButton = screen.getByTitle('Clear logs');
|
||||
await user.click(clearButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Log 1')).toBeFalsy();
|
||||
expect(screen.queryByText('Log 2')).toBeFalsy();
|
||||
expect(screen.getByText('No logs yet. Waiting for events...')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('limits the number of stored logs', async () => {
|
||||
render(<LiveLogViewer maxLogs={2} />);
|
||||
|
||||
// Add 3 logs (exceeding maxLogs)
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Log 1' });
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:01Z', message: 'Log 2' });
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:02Z', message: 'Log 3' });
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
// First log should be removed, only last 2 should remain
|
||||
expect(screen.queryByText('Log 1')).toBeFalsy();
|
||||
expect(screen.getByText('Log 2')).toBeTruthy();
|
||||
expect(screen.getByText('Log 3')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays log data when available', async () => {
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
const logWithData: logsApi.LiveLogEntry = {
|
||||
level: 'error',
|
||||
timestamp: '2025-12-09T10:30:00Z',
|
||||
message: 'Error occurred',
|
||||
data: { error_code: 500, details: 'Internal server error' },
|
||||
};
|
||||
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage(logWithData);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Error occurred')).toBeTruthy();
|
||||
// Check that data is rendered as JSON
|
||||
expect(screen.getByText(/"error_code"/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes WebSocket connection on unmount', () => {
|
||||
const { unmount } = render(<LiveLogViewer />);
|
||||
|
||||
expect(logsApi.connectLiveLogs).toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockCloseConnection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<LiveLogViewer className="custom-class" />);
|
||||
|
||||
const element = container.querySelector('.custom-class');
|
||||
expect(element).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows correct connection status', async () => {
|
||||
let mockOnOpen: (() => void) | undefined;
|
||||
let mockOnError: ((error: Event) => void) | undefined;
|
||||
|
||||
vi.mocked(logsApi.connectLiveLogs).mockImplementation((_filters, _onMessage, onOpen, onError) => {
|
||||
mockOnOpen = onOpen;
|
||||
mockOnError = onError;
|
||||
return mockCloseConnection as () => void;
|
||||
});
|
||||
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
// Initially disconnected until onOpen is called
|
||||
expect(screen.getByText('Disconnected')).toBeTruthy();
|
||||
|
||||
// Simulate connection opened
|
||||
if (mockOnOpen) {
|
||||
mockOnOpen();
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connected')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Simulate connection error
|
||||
if (mockOnError) {
|
||||
mockOnError(new Event('error'));
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Disconnected')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows no-match message when filters exclude all logs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
if (mockOnMessage) {
|
||||
mockOnMessage({ level: 'info', timestamp: '2025-12-09T10:30:00Z', message: 'Visible' });
|
||||
mockOnMessage({ level: 'error', timestamp: '2025-12-09T10:30:01Z', message: 'Hidden' });
|
||||
}
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Visible')).toBeTruthy());
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Filter by text...'), 'nomatch');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No logs match the current filters.')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('marks connection as disconnected when WebSocket closes', async () => {
|
||||
render(<LiveLogViewer />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Connected')).toBeTruthy());
|
||||
|
||||
mockOnClose?.();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Disconnected')).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { CharonLoader, CharonCoinLoader, CerberusLoader, ConfigReloadOverlay } from '../LoadingStates'
|
||||
|
||||
describe('CharonLoader', () => {
|
||||
it('renders boat animation with accessibility label', () => {
|
||||
render(<CharonLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CharonCoinLoader', () => {
|
||||
it('renders coin animation with accessibility label', () => {
|
||||
render(<CharonCoinLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CharonCoinLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonCoinLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CerberusLoader', () => {
|
||||
it('renders guardian animation with accessibility label', () => {
|
||||
render(<CerberusLoader />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
|
||||
it('renders with different sizes', () => {
|
||||
const { rerender } = render(<CerberusLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CerberusLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigReloadOverlay', () => {
|
||||
it('renders with Charon theme (default)', () => {
|
||||
render(<ConfigReloadOverlay />)
|
||||
expect(screen.getByText('Ferrying configuration...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Charon is crossing the Styx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with Coin theme', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Paying the ferryman..."
|
||||
submessage="Your obol grants passage"
|
||||
type="coin"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Your obol grants passage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with Cerberus theme', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Cerberus awakens..."
|
||||
submessage="Guardian of the gates stands watch"
|
||||
type="cerberus"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Cerberus awakens...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Guardian of the gates stands watch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with custom messages', () => {
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message="Custom message"
|
||||
submessage="Custom submessage"
|
||||
type="charon"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText('Custom message')).toBeInTheDocument()
|
||||
expect(screen.getByText('Custom submessage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct theme colors', () => {
|
||||
const { container, rerender } = render(<ConfigReloadOverlay type="charon" />)
|
||||
let overlay = container.querySelector('.bg-blue-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
rerender(<ConfigReloadOverlay type="coin" />)
|
||||
overlay = container.querySelector('.bg-amber-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
|
||||
rerender(<ConfigReloadOverlay type="cerberus" />)
|
||||
overlay = container.querySelector('.bg-red-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders as full-screen overlay with high z-index', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.querySelector('.fixed.inset-0.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,321 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
CharonLoader,
|
||||
CharonCoinLoader,
|
||||
CerberusLoader,
|
||||
ConfigReloadOverlay,
|
||||
} from '../LoadingStates'
|
||||
|
||||
describe('LoadingStates - Security Audit', () => {
|
||||
describe('CharonLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CharonLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles all size variants', () => {
|
||||
const { rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="md" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label', () => {
|
||||
render(<CharonLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CharonLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CharonLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CharonLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CharonCoinLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label for authentication', () => {
|
||||
render(<CharonCoinLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('renders gradient definition', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
const gradient = container.querySelector('#goldGradient')
|
||||
expect(gradient).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CharonCoinLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CharonCoinLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CharonCoinLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CerberusLoader', () => {
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has accessible role and label for security', () => {
|
||||
render(<CerberusLoader />)
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
|
||||
it('renders three heads (three circles for heads)', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
const circles = container.querySelectorAll('circle')
|
||||
// At least 3 head circles should exist (plus paws and eyes)
|
||||
expect(circles.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('applies correct size classes', () => {
|
||||
const { container, rerender } = render(<CerberusLoader size="sm" />)
|
||||
expect(container.firstChild).toHaveClass('w-12', 'h-12')
|
||||
|
||||
rerender(<CerberusLoader size="md" />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20')
|
||||
|
||||
rerender(<CerberusLoader size="lg" />)
|
||||
expect(container.firstChild).toHaveClass('w-28', 'h-28')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConfigReloadOverlay - XSS Protection', () => {
|
||||
it('renders with default props', () => {
|
||||
render(<ConfigReloadOverlay />)
|
||||
expect(screen.getByText('Ferrying configuration...')).toBeInTheDocument()
|
||||
expect(screen.getByText('Charon is crossing the Styx')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: prevents XSS in message prop', () => {
|
||||
const xssPayload = '<script>alert("XSS")</script>'
|
||||
render(<ConfigReloadOverlay message={xssPayload} />)
|
||||
|
||||
// React should escape this automatically
|
||||
expect(screen.getByText(xssPayload)).toBeInTheDocument()
|
||||
expect(document.querySelector('script')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: prevents XSS in submessage prop', () => {
|
||||
const xssPayload = '<img src=x onerror="alert(1)">'
|
||||
render(<ConfigReloadOverlay submessage={xssPayload} />)
|
||||
|
||||
expect(screen.getByText(xssPayload)).toBeInTheDocument()
|
||||
expect(document.querySelector('img[onerror]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: handles extremely long messages', () => {
|
||||
const longMessage = 'A'.repeat(10000)
|
||||
const { container } = render(<ConfigReloadOverlay message={longMessage} />)
|
||||
|
||||
// Should render without crashing
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(screen.getByText(longMessage)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: handles special characters', () => {
|
||||
const specialChars = '!@#$%^&*()_+-=[]{}|;:",.<>?/~`'
|
||||
render(
|
||||
<ConfigReloadOverlay
|
||||
message={specialChars}
|
||||
submessage={specialChars}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getAllByText(specialChars)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ATTACK: handles unicode and emoji', () => {
|
||||
const unicode = '🔥💀🐕🦺 λ µ π Σ 中文 العربية עברית'
|
||||
render(<ConfigReloadOverlay message={unicode} />)
|
||||
|
||||
expect(screen.getByText(unicode)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - charon (blue)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="charon" />)
|
||||
const overlay = container.querySelector('.bg-blue-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - coin (gold)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="coin" />)
|
||||
const overlay = container.querySelector('.bg-amber-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct theme - cerberus (red)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type="cerberus" />)
|
||||
const overlay = container.querySelector('.bg-red-950\\/90')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies correct z-index (z-50)', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.querySelector('.z-50')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies backdrop blur', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const backdrop = container.querySelector('.backdrop-blur-sm')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('ATTACK: type prop injection attempt', () => {
|
||||
// @ts-expect-error - Testing invalid type
|
||||
const { container } = render(<ConfigReloadOverlay type="<script>alert(1)</script>" />)
|
||||
|
||||
// Should default to charon theme
|
||||
expect(container.querySelector('.bg-blue-950\\/90')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Overlay Integration Tests', () => {
|
||||
it('CharonLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="charon" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Loading')
|
||||
})
|
||||
|
||||
it('CharonCoinLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="coin" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Authenticating')
|
||||
})
|
||||
|
||||
it('CerberusLoader renders inside overlay', () => {
|
||||
render(<ConfigReloadOverlay type="cerberus" />)
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Security Loading')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CSS Animation Requirements', () => {
|
||||
it('CharonLoader uses animate-bob-boat class', () => {
|
||||
const { container } = render(<CharonLoader />)
|
||||
const animated = container.querySelector('.animate-bob-boat')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CharonCoinLoader uses animate-spin-y class', () => {
|
||||
const { container } = render(<CharonCoinLoader />)
|
||||
const animated = container.querySelector('.animate-spin-y')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CerberusLoader uses animate-rotate-head class', () => {
|
||||
const { container } = render(<CerberusLoader />)
|
||||
const animated = container.querySelector('.animate-rotate-head')
|
||||
expect(animated).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles undefined size prop gracefully', () => {
|
||||
const { container } = render(<CharonLoader size={undefined} />)
|
||||
expect(container.firstChild).toHaveClass('w-20', 'h-20') // defaults to md
|
||||
})
|
||||
|
||||
it('handles null message', () => {
|
||||
// @ts-expect-error - Testing null
|
||||
render(<ConfigReloadOverlay message={null} />)
|
||||
// Null message renders as empty paragraph - component gracefully handles null
|
||||
const textContainer = screen.getByText(/Charon is crossing the Styx/i).closest('div')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles empty string message', () => {
|
||||
render(<ConfigReloadOverlay message="" submessage="" />)
|
||||
// Should render but be empty
|
||||
expect(screen.queryByText('Ferrying configuration...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles undefined type prop', () => {
|
||||
const { container } = render(<ConfigReloadOverlay type={undefined} />)
|
||||
// Should default to charon
|
||||
expect(container.querySelector('.bg-blue-950\\/90')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility Requirements', () => {
|
||||
it('overlay is keyboard accessible', () => {
|
||||
const { container } = render(<ConfigReloadOverlay />)
|
||||
const overlay = container.firstChild
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('all loaders have status role', () => {
|
||||
render(
|
||||
<>
|
||||
<CharonLoader />
|
||||
<CharonCoinLoader />
|
||||
<CerberusLoader />
|
||||
</>
|
||||
)
|
||||
const statuses = screen.getAllByRole('status')
|
||||
expect(statuses).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('all loaders have aria-label', () => {
|
||||
const { container: c1 } = render(<CharonLoader />)
|
||||
const { container: c2 } = render(<CharonCoinLoader />)
|
||||
const { container: c3 } = render(<CerberusLoader />)
|
||||
|
||||
expect(c1.firstChild).toHaveAttribute('aria-label')
|
||||
expect(c2.firstChild).toHaveAttribute('aria-label')
|
||||
expect(c3.firstChild).toHaveAttribute('aria-label')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance Tests', () => {
|
||||
it('renders CharonLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CharonLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100) // Should render in <100ms
|
||||
})
|
||||
|
||||
it('renders CharonCoinLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CharonCoinLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('renders CerberusLoader quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<CerberusLoader />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('renders ConfigReloadOverlay quickly', () => {
|
||||
const start = performance.now()
|
||||
render(<ConfigReloadOverlay />)
|
||||
const end = performance.now()
|
||||
expect(end - start).toBeLessThan(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,172 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import NotificationCenter from '../NotificationCenter'
|
||||
import * as api from '../../api/system'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/system', () => ({
|
||||
getNotifications: vi.fn(),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const mockNotifications: api.Notification[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'info',
|
||||
title: 'Info Notification',
|
||||
message: 'This is an info message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'success',
|
||||
title: 'Success Notification',
|
||||
message: 'This is a success message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'warning',
|
||||
title: 'Warning Notification',
|
||||
message: 'This is a warning message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'error',
|
||||
title: 'Error Notification',
|
||||
message: 'This is an error message',
|
||||
read: false,
|
||||
created_at: '2025-01-01T13:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
describe('NotificationCenter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.checkUpdates).mockResolvedValue({
|
||||
available: false,
|
||||
latest_version: '0.0.0',
|
||||
changelog_url: '',
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders bell icon and unread count', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByRole('button', { name: /notifications/i })).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens notification panel on click', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
const bellButton = screen.getByRole('button', { name: /notifications/i })
|
||||
await userEvent.click(bellButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument()
|
||||
expect(screen.getByText('Info Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Success Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Warning Notification')).toBeInTheDocument()
|
||||
expect(screen.getByText('Error Notification')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays empty state when no notifications', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue([])
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
const bellButton = screen.getByRole('button', { name: /notifications/i })
|
||||
await userEvent.click(bellButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No new notifications')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('marks single notification as read', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
vi.mocked(api.markNotificationRead).mockResolvedValue()
|
||||
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Info Notification')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const closeButtons = screen.getAllByRole('button', { name: /close/i })
|
||||
await userEvent.click(closeButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.markNotificationRead).toHaveBeenCalledWith('1', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
it('marks all notifications as read', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
vi.mocked(api.markAllNotificationsRead).mockResolvedValue()
|
||||
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mark all read')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText('Mark all read'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.markAllNotificationsRead).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes panel when clicking outside', async () => {
|
||||
vi.mocked(api.getNotifications).mockResolvedValue(mockNotifications)
|
||||
render(<NotificationCenter />, { wrapper: createWrapper() })
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /notifications/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Notifications')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('notification-backdrop'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Notifications')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { PasswordStrengthMeter } from '../PasswordStrengthMeter'
|
||||
|
||||
describe('PasswordStrengthMeter', () => {
|
||||
it('renders nothing when password is empty', () => {
|
||||
const { container } = render(<PasswordStrengthMeter password="" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('renders strength label when password is provided', () => {
|
||||
render(<PasswordStrengthMeter password="password123" />)
|
||||
// Depending on the implementation, it might show "Weak", "Fair", etc.
|
||||
// "password123" is likely weak or fair.
|
||||
// Let's just check if any text is rendered.
|
||||
expect(screen.getByText(/Weak|Fair|Good|Strong/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders progress bars', () => {
|
||||
render(<PasswordStrengthMeter password="password123" />)
|
||||
// It usually renders 4 bars
|
||||
// In the implementation I read, it renders one bar with width.
|
||||
// <div className="h-1.5 w-full ..."><div className="h-full ..." style={{ width: ... }} /></div>
|
||||
// So we can check for the progress bar container or the inner bar.
|
||||
// Let's check for the label text which we already did.
|
||||
// Let's check if the feedback is shown if present.
|
||||
// For "password123", it might have feedback.
|
||||
// But let's just stick to checking the label for now as "renders progress bars" was a bit vague in my previous attempt.
|
||||
// I'll replace this test with something more specific or just remove it if covered by others.
|
||||
// Actually, let's check that the bar exists.
|
||||
// It doesn't have a role, so we can't use getByRole('progressbar').
|
||||
// We can check if the container has the class 'bg-gray-200' or 'dark:bg-gray-700'.
|
||||
// But testing implementation details (classes) is brittle.
|
||||
// Let's just check that the component renders without crashing and shows the label.
|
||||
expect(screen.getByText(/Weak|Fair|Good|Strong/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('updates label based on password strength', () => {
|
||||
const { rerender } = render(<PasswordStrengthMeter password="123" />)
|
||||
expect(screen.getByText('Weak')).toBeInTheDocument()
|
||||
|
||||
rerender(<PasswordStrengthMeter password="CorrectHorseBatteryStaple1!" />)
|
||||
expect(screen.getByText('Strong')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ProxyHostForm from '../ProxyHostForm'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
vi.mock('../../api/uptime', () => ({
|
||||
syncMonitors: vi.fn(() => Promise.resolve({})),
|
||||
}))
|
||||
|
||||
// Minimal hook mocks used by the component
|
||||
vi.mock('../../hooks/useRemoteServers', () => ({
|
||||
useRemoteServers: vi.fn(() => ({
|
||||
servers: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
createRemoteServer: vi.fn(),
|
||||
updateRemoteServer: vi.fn(),
|
||||
deleteRemoteServer: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDocker', () => ({
|
||||
useDocker: vi.fn(() => ({ containers: [], isLoading: false, error: null, refetch: vi.fn() })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDomains', () => ({
|
||||
useDomains: vi.fn(() => ({ domains: [], createDomain: vi.fn().mockResolvedValue({}), isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({ certificates: [], isLoading: false, error: null })),
|
||||
}))
|
||||
|
||||
// stub global fetch for health endpoint
|
||||
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ json: () => Promise.resolve({ internal_ip: '127.0.0.1' }) })))
|
||||
|
||||
describe('ProxyHostForm Add Uptime flow', () => {
|
||||
it('submits host and requests uptime sync when Add Uptime is checked', async () => {
|
||||
const onSubmit = vi.fn(() => Promise.resolve())
|
||||
const onCancel = vi.fn()
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ProxyHostForm onSubmit={onSubmit} onCancel={onCancel} />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Service')
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'example.com')
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '127.0.0.1')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '8080')
|
||||
|
||||
// Check Add Uptime
|
||||
const addUptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i)
|
||||
await userEvent.click(addUptimeCheckbox)
|
||||
|
||||
// Adjust uptime options — locate the container for the uptime inputs
|
||||
const uptimeCheckbox = screen.getByLabelText(/Add Uptime monitoring for this host/i)
|
||||
const uptimeContainer = uptimeCheckbox.closest('label')?.parentElement
|
||||
if (!uptimeContainer) throw new Error('Uptime container not found')
|
||||
|
||||
const { within } = await import('@testing-library/react')
|
||||
const spinbuttons = within(uptimeContainer).getAllByRole('spinbutton')
|
||||
// first spinbutton is interval, second is max retries
|
||||
fireEvent.change(spinbuttons[0], { target: { value: '30' } })
|
||||
fireEvent.change(spinbuttons[1], { target: { value: '2' } })
|
||||
|
||||
// Submit
|
||||
const submitBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement
|
||||
if (!submitBtn) throw new Error('Submit button not found')
|
||||
await userEvent.click(submitBtn)
|
||||
|
||||
// wait for onSubmit to have been called
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
||||
|
||||
// Ensure uptime API was called with provided options
|
||||
const uptime = await import('../../api/uptime')
|
||||
await waitFor(() => expect(uptime.syncMonitors).toHaveBeenCalledWith({ interval: 30, max_retries: 2 }))
|
||||
|
||||
// Ensure onSubmit payload does not include temporary uptime keys
|
||||
const onSubmitMock = onSubmit as unknown as import('vitest').Mock
|
||||
const submittedPayload = onSubmitMock.mock.calls[0][0]
|
||||
expect(submittedPayload).not.toHaveProperty('addUptime')
|
||||
expect(submittedPayload).not.toHaveProperty('uptimeInterval')
|
||||
expect(submittedPayload).not.toHaveProperty('uptimeMaxRetries')
|
||||
})
|
||||
})
|
||||
@@ -1,660 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { act } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ProxyHostForm from '../ProxyHostForm'
|
||||
import type { ProxyHost } from '../../api/proxyHosts'
|
||||
import { mockRemoteServers } from '../../test/mockData'
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../hooks/useRemoteServers', () => ({
|
||||
useRemoteServers: vi.fn(() => ({
|
||||
servers: mockRemoteServers,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
createRemoteServer: vi.fn(),
|
||||
updateRemoteServer: vi.fn(),
|
||||
deleteRemoteServer: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDocker', () => ({
|
||||
useDocker: vi.fn(() => ({
|
||||
containers: [
|
||||
{
|
||||
id: 'container-123',
|
||||
names: ['my-app'],
|
||||
image: 'nginx:latest',
|
||||
state: 'running',
|
||||
status: 'Up 2 hours',
|
||||
network: 'bridge',
|
||||
ip: '172.17.0.2',
|
||||
ports: [{ private_port: 80, public_port: 8080, type: 'tcp' }]
|
||||
}
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useDomains', () => ({
|
||||
useDomains: vi.fn(() => ({
|
||||
domains: [
|
||||
{ uuid: 'domain-1', name: 'existing.com' }
|
||||
],
|
||||
createDomain: vi.fn().mockResolvedValue({ uuid: 'domain-1', name: 'existing.com' }),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useCertificates', () => ({
|
||||
useCertificates: vi.fn(() => ({
|
||||
certificates: [
|
||||
{ id: 1, name: 'Cert 1', domain: 'example.com', provider: 'custom', issuer: 'Custom', expires_at: '2026-01-01' }
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useSecurity', () => ({
|
||||
useAuthPolicies: vi.fn(() => ({
|
||||
policies: [
|
||||
{ id: 1, name: 'Admin Only', description: 'Requires admin role' }
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/proxyHosts', () => ({
|
||||
testProxyHostConnection: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock global fetch for health API
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const renderWithClientAct = async (ui: React.ReactElement) => {
|
||||
await act(async () => {
|
||||
renderWithClient(ui)
|
||||
})
|
||||
}
|
||||
|
||||
import { testProxyHostConnection } from '../../api/proxyHosts'
|
||||
|
||||
describe('ProxyHostForm', () => {
|
||||
const mockOnSubmit = vi.fn(() => Promise.resolve())
|
||||
const mockOnCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
// Default fetch mock for health endpoint
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ internal_ip: '192.168.1.50' }),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('handles scheme selection', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Add Proxy Host')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Find scheme select - it defaults to HTTP
|
||||
// We can find it by label "Scheme"
|
||||
const schemeSelect = screen.getByLabelText('Scheme') as HTMLSelectElement
|
||||
await userEvent.selectOptions(schemeSelect, 'https')
|
||||
|
||||
expect(schemeSelect).toHaveValue('https')
|
||||
})
|
||||
|
||||
it('prompts to save new base domain', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
|
||||
// Enter a subdomain of a new base domain
|
||||
await userEvent.type(domainInput, 'sub.newdomain.com')
|
||||
await userEvent.tab()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('newdomain.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click "Yes, save it"
|
||||
await userEvent.click(screen.getByText('Yes, save it'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('respects "Dont ask me again" for new domains', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const domainInput = screen.getByPlaceholderText('example.com, www.example.com')
|
||||
|
||||
// Trigger prompt
|
||||
await userEvent.type(domainInput, 'sub.another.com')
|
||||
await userEvent.tab()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('New Base Domain Detected')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Check "Don't ask me again"
|
||||
await userEvent.click(screen.getByLabelText("Don't ask me again"))
|
||||
|
||||
// Click "No, thanks"
|
||||
await userEvent.click(screen.getByText('No, thanks'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Try another new domain - should not prompt
|
||||
await userEvent.type(domainInput, 'sub.yetanother.com')
|
||||
await userEvent.tab()
|
||||
|
||||
// Should not see prompt
|
||||
expect(screen.queryByText('New Base Domain Detected')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tests connection successfully', async () => {
|
||||
vi.mocked(testProxyHostConnection).mockResolvedValue(undefined)
|
||||
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill required fields for test connection
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '10.0.0.5')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '80')
|
||||
|
||||
const testBtn = screen.getByTitle('Test connection to the forward host')
|
||||
await userEvent.click(testBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testProxyHostConnection).toHaveBeenCalledWith('10.0.0.5', 80)
|
||||
})
|
||||
})
|
||||
|
||||
it('handles connection test failure', async () => {
|
||||
vi.mocked(testProxyHostConnection).mockRejectedValue(new Error('Connection failed'))
|
||||
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '10.0.0.5')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '80')
|
||||
|
||||
const testBtn = screen.getByTitle('Test connection to the forward host')
|
||||
await userEvent.click(testBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testProxyHostConnection).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Should show error state (red button) - we can check class or icon
|
||||
// The button changes class to bg-red-600
|
||||
await waitFor(() => {
|
||||
expect(testBtn).toHaveClass('bg-red-600')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles base domain selection', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Base Domain (Auto-fill)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText('Base Domain (Auto-fill)') as HTMLSelectElement, 'existing.com')
|
||||
|
||||
// Should not update domain names yet as no container selected
|
||||
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('')
|
||||
|
||||
// Select container then base domain
|
||||
await userEvent.selectOptions(screen.getByLabelText('Source') as HTMLSelectElement, 'local')
|
||||
await userEvent.selectOptions(screen.getByLabelText('Containers') as HTMLSelectElement, 'container-123')
|
||||
await userEvent.selectOptions(screen.getByLabelText('Base Domain (Auto-fill)') as HTMLSelectElement, 'existing.com')
|
||||
|
||||
expect(screen.getByLabelText(/Domain Names/i)).toHaveValue('my-app.existing.com')
|
||||
})
|
||||
|
||||
// Application Preset Tests
|
||||
describe('Application Presets', () => {
|
||||
it('renders application preset dropdown with all options', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const presetSelect = screen.getByLabelText(/Application Preset/i)
|
||||
expect(presetSelect).toBeInTheDocument()
|
||||
|
||||
// Check that all presets are available
|
||||
expect(screen.getByText('None - Standard reverse proxy')).toBeInTheDocument()
|
||||
expect(screen.getByText('Plex - Media server with remote access')).toBeInTheDocument()
|
||||
expect(screen.getByText('Jellyfin - Open source media server')).toBeInTheDocument()
|
||||
expect(screen.getByText('Emby - Media server')).toBeInTheDocument()
|
||||
expect(screen.getByText('Home Assistant - Home automation')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nextcloud - File sync and share')).toBeInTheDocument()
|
||||
expect(screen.getByText('Vaultwarden - Password manager')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to none preset', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const presetSelect = screen.getByLabelText(/Application Preset/i)
|
||||
expect(presetSelect).toHaveValue('none')
|
||||
})
|
||||
|
||||
it('enables websockets when selecting plex preset', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// First uncheck websockets
|
||||
const websocketCheckbox = screen.getByLabelText(/Websockets Support/i)
|
||||
if (websocketCheckbox.getAttribute('checked') !== null) {
|
||||
await userEvent.click(websocketCheckbox)
|
||||
}
|
||||
|
||||
// Select Plex preset
|
||||
await act(async () => {
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
})
|
||||
|
||||
// Websockets should be enabled
|
||||
expect(screen.getByLabelText(/Websockets Support/i)).toBeChecked()
|
||||
})
|
||||
|
||||
it('shows plex config helper with external URL when preset is selected', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.mydomain.com')
|
||||
|
||||
// Select Plex preset
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
|
||||
// Should show the helper with external URL
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://plex.mydomain.com:443')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows jellyfin config helper with internal IP', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'jellyfin.mydomain.com')
|
||||
|
||||
// Select Jellyfin preset
|
||||
await act(async () => {
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'jellyfin')
|
||||
})
|
||||
|
||||
// Wait for health API fetch and show helper
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Jellyfin Proxy Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText('192.168.1.50')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows home assistant config helper with yaml snippet', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'ha.mydomain.com')
|
||||
|
||||
// Select Home Assistant preset
|
||||
await act(async () => {
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'homeassistant')
|
||||
})
|
||||
|
||||
// Wait for health API fetch and show helper
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Home Assistant Proxy Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText(/use_x_forwarded_for/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/192\.168\.1\.50/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows nextcloud config helper with php snippet', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'nextcloud.mydomain.com')
|
||||
|
||||
// Select Nextcloud preset
|
||||
await act(async () => {
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'nextcloud')
|
||||
})
|
||||
|
||||
// Wait for health API fetch and show helper
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Nextcloud Proxy Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText(/trusted_proxies/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/overwriteprotocol/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows vaultwarden helper text', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'vault.mydomain.com')
|
||||
|
||||
// Select Vaultwarden preset
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'vaultwarden')
|
||||
|
||||
// Wait for helper text
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Vaultwarden Setup')).toBeInTheDocument()
|
||||
expect(screen.getByText(/WebSocket support is enabled automatically/)).toBeInTheDocument()
|
||||
expect(screen.getByText('vault.mydomain.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('auto-detects plex preset from container image', async () => {
|
||||
// Mock useDocker to return a Plex container
|
||||
const { useDocker } = await import('../../hooks/useDocker')
|
||||
vi.mocked(useDocker).mockReturnValue({
|
||||
containers: [
|
||||
{
|
||||
id: 'plex-container',
|
||||
names: ['plex'],
|
||||
image: 'linuxserver/plex:latest',
|
||||
state: 'running',
|
||||
status: 'Up 1 hour',
|
||||
network: 'bridge',
|
||||
ip: '172.17.0.3',
|
||||
ports: [{ private_port: 32400, public_port: 32400, type: 'tcp' }]
|
||||
}
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Select local source
|
||||
await userEvent.selectOptions(screen.getByLabelText('Source') as HTMLSelectElement, 'local')
|
||||
|
||||
// Select the plex container
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('plex (linuxserver/plex:latest)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText('Containers') as HTMLSelectElement, 'plex-container')
|
||||
|
||||
// The preset should be auto-detected as plex
|
||||
expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
|
||||
})
|
||||
|
||||
it('auto-populates advanced_config when selecting plex preset and field empty', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Ensure advanced config is empty
|
||||
const textarea = screen.getByLabelText(/Advanced Caddy Config/i)
|
||||
expect(textarea).toHaveValue('')
|
||||
|
||||
// Select Plex preset
|
||||
await act(async () => {
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
})
|
||||
|
||||
// Value should now be populated with a snippet containing X-Real-IP which is used by the preset
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Real-IP')
|
||||
})
|
||||
})
|
||||
|
||||
it('prompts to confirm overwrite when selecting preset and advanced_config is non-empty', async () => {
|
||||
const existingHost = {
|
||||
uuid: 'test-uuid',
|
||||
name: 'ConfTest',
|
||||
domain_names: 'test.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.2',
|
||||
forward_port: 8080,
|
||||
advanced_config: '{"handler":"headers","request":{"set":{"X-Test":"value"}}}',
|
||||
advanced_config_backup: '',
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: true,
|
||||
application: 'none' as const,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm host={existingHost as ProxyHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Select Plex preset (should prompt since advanced_config is non-empty)
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Confirm Preset Overwrite')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click Overwrite
|
||||
await userEvent.click(screen.getByText('Overwrite'))
|
||||
|
||||
// After overwrite, the textarea should contain the preset 'X-Real-IP' snippet
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Real-IP')
|
||||
})
|
||||
})
|
||||
|
||||
it('restores previous advanced_config from backup when clicking restore', async () => {
|
||||
const existingHost = {
|
||||
uuid: 'test-uuid',
|
||||
name: 'RestoreTest',
|
||||
domain_names: 'test.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.2',
|
||||
forward_port: 8080,
|
||||
advanced_config: '{"handler":"headers","request":{"set":{"X-Test":"value"}}}',
|
||||
advanced_config_backup: '{"handler":"headers","request":{"set":{"X-Prev":"backup"}}}',
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: true,
|
||||
application: 'none' as const,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm host={existingHost as ProxyHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// The restore button should be visible
|
||||
const restoreBtn = await screen.findByText('Restore previous config')
|
||||
expect(restoreBtn).toBeInTheDocument()
|
||||
|
||||
// Click restore and expect the textarea to have backup value
|
||||
await userEvent.click(restoreBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText(/Advanced Caddy Config/i) as HTMLTextAreaElement).value).toContain('X-Prev')
|
||||
})
|
||||
})
|
||||
|
||||
it('includes application field in form submission', async () => {
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.type(screen.getByPlaceholderText('My Service'), 'My Plex Server')
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.test.com')
|
||||
await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100')
|
||||
await userEvent.clear(screen.getByLabelText(/^Port$/))
|
||||
await userEvent.type(screen.getByLabelText(/^Port$/), '32400')
|
||||
|
||||
// Select Plex preset
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
|
||||
// Submit form
|
||||
await userEvent.click(screen.getByText('Save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
application: 'plex',
|
||||
websocket_support: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('loads existing host application preset', async () => {
|
||||
const existingHost = {
|
||||
uuid: 'test-uuid',
|
||||
name: 'Existing Plex',
|
||||
domain_names: 'plex.example.com',
|
||||
forward_scheme: 'http',
|
||||
forward_host: '192.168.1.100',
|
||||
forward_port: 32400,
|
||||
ssl_forced: true,
|
||||
http2_support: true,
|
||||
hsts_enabled: true,
|
||||
hsts_subdomains: false,
|
||||
block_exploits: true,
|
||||
websocket_support: true,
|
||||
application: 'plex' as const,
|
||||
locations: [],
|
||||
enabled: true,
|
||||
created_at: '2025-01-01',
|
||||
updated_at: '2025-01-01',
|
||||
}
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm host={existingHost} onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// The preset should be pre-selected
|
||||
expect(screen.getByLabelText(/Application Preset/i)).toHaveValue('plex')
|
||||
|
||||
// The config helper should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show config helper when preset is none', async () => {
|
||||
await renderWithClientAct(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await act(async () => {
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'test.mydomain.com')
|
||||
})
|
||||
|
||||
// Preset defaults to none, so no helper should be shown
|
||||
expect(screen.queryByText('Plex Remote Access Setup')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Jellyfin Proxy Setup')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Home Assistant Proxy Setup')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies external URL to clipboard for plex', async () => {
|
||||
// Mock clipboard API
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: mockWriteText },
|
||||
})
|
||||
|
||||
renderWithClient(
|
||||
<ProxyHostForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill in domain names
|
||||
await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'plex.mydomain.com')
|
||||
|
||||
// Select Plex preset
|
||||
await userEvent.selectOptions(screen.getByLabelText(/Application Preset/i) as HTMLSelectElement, 'plex')
|
||||
|
||||
// Wait for helper to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Plex Remote Access Setup')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click the copy button
|
||||
const copyButtons = screen.getAllByText('Copy')
|
||||
await userEvent.click(copyButtons[0])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith('https://plex.mydomain.com:443')
|
||||
expect(screen.getByText('Copied!')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,200 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import RemoteServerForm from '../RemoteServerForm'
|
||||
import * as remoteServersApi from '../../api/remoteServers'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/remoteServers', () => ({
|
||||
testRemoteServerConnection: vi.fn(() => Promise.resolve({ address: 'localhost:8080' })),
|
||||
testCustomRemoteServerConnection: vi.fn(() => Promise.resolve({ address: 'localhost:8080', reachable: true })),
|
||||
}))
|
||||
|
||||
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 in create and edit mode', () => {
|
||||
const { rerender } = render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Connection')).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', async () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Cancel'))
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
await userEvent.clear(nameInput)
|
||||
await userEvent.type(nameInput, 'New Server')
|
||||
await userEvent.clear(hostInput)
|
||||
await userEvent.type(hostInput, '10.0.0.5')
|
||||
await userEvent.clear(portInput)
|
||||
await userEvent.type(portInput, '9090')
|
||||
|
||||
await userEvent.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', async () => {
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
const providerSelect = screen.getByDisplayValue('Generic')
|
||||
await userEvent.selectOptions(providerSelect, 'docker')
|
||||
|
||||
expect(providerSelect).toHaveValue('docker')
|
||||
})
|
||||
|
||||
it('handles submission error', async () => {
|
||||
const mockErrorSubmit = vi.fn(() => Promise.reject(new Error('Submission failed')))
|
||||
render(
|
||||
<RemoteServerForm onSubmit={mockErrorSubmit} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Fill required fields
|
||||
await userEvent.clear(screen.getByPlaceholderText('My Production Server'))
|
||||
await userEvent.type(screen.getByPlaceholderText('My Production Server'), 'Test Server')
|
||||
await userEvent.clear(screen.getByPlaceholderText('192.168.1.100'))
|
||||
await userEvent.type(screen.getByPlaceholderText('192.168.1.100'), '10.0.0.1')
|
||||
|
||||
await userEvent.click(screen.getByText('Create'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Submission failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('handles test connection success', async () => {
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
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} />
|
||||
)
|
||||
|
||||
const testButton = screen.getByText('Test Connection')
|
||||
await userEvent.click(testButton)
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for success state (green background)
|
||||
expect(testButton).toHaveClass('bg-green-600')
|
||||
})
|
||||
})
|
||||
|
||||
it('handles test connection failure', async () => {
|
||||
// Override mock for this test
|
||||
vi.mocked(remoteServersApi.testCustomRemoteServerConnection).mockRejectedValueOnce(new Error('Connection failed'))
|
||||
|
||||
const mockServer = {
|
||||
uuid: '123',
|
||||
name: 'Test Server',
|
||||
provider: 'docker',
|
||||
host: 'localhost',
|
||||
port: 5000,
|
||||
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} />
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByText('Test Connection'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connection failed')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,299 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { SecurityNotificationSettingsModal } from '../SecurityNotificationSettingsModal';
|
||||
import { createTestQueryClient } from '../../test/createTestQueryClient';
|
||||
import * as notificationsApi from '../../api/notifications';
|
||||
|
||||
// Mock the API
|
||||
vi.mock('../../api/notifications', async () => {
|
||||
const actual = await vi.importActual('../../api/notifications');
|
||||
return {
|
||||
...actual,
|
||||
getSecurityNotificationSettings: vi.fn(),
|
||||
updateSecurityNotificationSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock toast
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('SecurityNotificationSettingsModal', () => {
|
||||
const mockSettings: notificationsApi.SecurityNotificationSettings = {
|
||||
enabled: true,
|
||||
min_log_level: 'warn',
|
||||
notify_waf_blocks: true,
|
||||
notify_acl_denials: true,
|
||||
notify_rate_limit_hits: false,
|
||||
webhook_url: 'https://example.com/webhook',
|
||||
email_recipients: 'admin@example.com',
|
||||
};
|
||||
|
||||
let queryClient: ReturnType<typeof createTestQueryClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = createTestQueryClient();
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockResolvedValue(mockSettings);
|
||||
vi.mocked(notificationsApi.updateSecurityNotificationSettings).mockResolvedValue(mockSettings);
|
||||
});
|
||||
|
||||
const renderModal = (isOpen = true, onClose = vi.fn()) => {
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SecurityNotificationSettingsModal isOpen={isOpen} onClose={onClose} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('does not render when isOpen is false', () => {
|
||||
renderModal(false);
|
||||
expect(screen.queryByText('Security Notification Settings')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders the modal when isOpen is true', async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('loads and displays existing settings', async () => {
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Check that settings are loaded
|
||||
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
|
||||
expect(enableSwitch.checked).toBe(true);
|
||||
|
||||
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
|
||||
expect(levelSelect.value).toBe('warn');
|
||||
|
||||
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
|
||||
expect(webhookInput.value).toBe('https://example.com/webhook');
|
||||
});
|
||||
|
||||
it('closes modal when close button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes modal when clicking outside', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
const { container } = renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click on the backdrop
|
||||
const backdrop = container.querySelector('.fixed.inset-0');
|
||||
if (backdrop) {
|
||||
await user.click(backdrop);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('submits updated settings', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Change minimum log level
|
||||
const levelSelect = screen.getByLabelText(/minimum log level/i);
|
||||
await user.selectOptions(levelSelect, 'error');
|
||||
|
||||
// Change webhook URL
|
||||
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i);
|
||||
await user.clear(webhookInput);
|
||||
await user.type(webhookInput, 'https://new-webhook.com');
|
||||
|
||||
// Submit form
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
min_log_level: 'error',
|
||||
webhook_url: 'https://new-webhook.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Modal should close on success
|
||||
await waitFor(() => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles notification enable/disable', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Enable Notifications')).toBeTruthy();
|
||||
});
|
||||
|
||||
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
|
||||
expect(enableSwitch.checked).toBe(true);
|
||||
|
||||
// Disable notifications
|
||||
await user.click(enableSwitch);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(enableSwitch.checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables controls when notifications are disabled', async () => {
|
||||
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockResolvedValue({
|
||||
...mockSettings,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
renderModal();
|
||||
|
||||
// Wait for settings to be loaded and form to render
|
||||
await waitFor(() => {
|
||||
const enableSwitch = screen.getByLabelText('Enable Notifications') as HTMLInputElement;
|
||||
expect(enableSwitch.checked).toBe(false);
|
||||
});
|
||||
|
||||
const levelSelect = screen.getByLabelText(/minimum log level/i) as HTMLSelectElement;
|
||||
expect(levelSelect.disabled).toBe(true);
|
||||
|
||||
const webhookInput = screen.getByPlaceholderText(/your-webhook-endpoint/i) as HTMLInputElement;
|
||||
expect(webhookInput.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles event type filters', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WAF Blocks')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Find and toggle WAF blocks switch
|
||||
const wafSwitch = screen.getByLabelText('WAF Blocks') as HTMLInputElement;
|
||||
expect(wafSwitch.checked).toBe(true);
|
||||
|
||||
await user.click(wafSwitch);
|
||||
|
||||
// Submit form
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
notify_waf_blocks: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
|
||||
vi.mocked(notificationsApi.updateSecurityNotificationSettings).mockRejectedValue(
|
||||
new Error('API Error')
|
||||
);
|
||||
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Submit form
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Modal should NOT close on error
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
vi.mocked(notificationsApi.getSecurityNotificationSettings).mockReturnValue(
|
||||
new Promise(() => {}) // Never resolves
|
||||
);
|
||||
|
||||
renderModal();
|
||||
|
||||
expect(screen.getByText('Loading settings...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles email recipients input', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/admin@example.com/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
const emailInput = screen.getByPlaceholderText(/admin@example.com/i);
|
||||
await user.clear(emailInput);
|
||||
await user.type(emailInput, 'user1@test.com, user2@test.com');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /save settings/i });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notificationsApi.updateSecurityNotificationSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email_recipients: 'user1@test.com, user2@test.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents modal content clicks from closing modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = vi.fn();
|
||||
renderModal(true, mockOnClose);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security Notification Settings')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click inside the modal content
|
||||
const modalContent = screen.getByText('Security Notification Settings');
|
||||
await user.click(modalContent);
|
||||
|
||||
// Modal should not close
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import SystemStatus from '../SystemStatus'
|
||||
import * as systemApi from '../../api/system'
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('../../api/system', () => ({
|
||||
checkUpdates: vi.fn(),
|
||||
}))
|
||||
|
||||
const renderWithClient = (ui: React.ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{ui}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SystemStatus', () => {
|
||||
it('calls checkUpdates on mount', async () => {
|
||||
vi.mocked(systemApi.checkUpdates).mockResolvedValue({
|
||||
available: false,
|
||||
latest_version: '1.0.0',
|
||||
changelog_url: '',
|
||||
})
|
||||
|
||||
renderWithClient(<SystemStatus />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(systemApi.checkUpdates).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,117 +0,0 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface CertificateCleanupDialogProps {
|
||||
onConfirm: (deleteCerts: boolean) => void
|
||||
onCancel: () => void
|
||||
certificates: Array<{ id: number; name: string; domain: string }>
|
||||
hostNames: string[]
|
||||
isBulk?: boolean
|
||||
}
|
||||
|
||||
export default function CertificateCleanupDialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
certificates,
|
||||
hostNames,
|
||||
isBulk = false
|
||||
}: CertificateCleanupDialogProps) {
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const deleteCerts = formData.get('delete_certs') === 'on'
|
||||
onConfirm(deleteCerts)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="bg-dark-card border border-orange-900/50 rounded-lg p-6 max-w-lg w-full mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-orange-900/30 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
Delete {isBulk ? `${hostNames.length} Proxy Hosts` : 'Proxy Host'}?
|
||||
</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{isBulk ? 'These hosts will be permanently deleted.' : 'This host will be permanently deleted.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Host names */}
|
||||
<div className="bg-gray-900/50 border border-gray-800 rounded-lg p-4 mb-4">
|
||||
<p className="text-xs font-medium text-gray-400 uppercase mb-2">
|
||||
{isBulk ? 'Hosts to be deleted:' : 'Host to be deleted:'}
|
||||
</p>
|
||||
<ul className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{hostNames.map((name, idx) => (
|
||||
<li key={idx} className="text-sm text-white flex items-center gap-2">
|
||||
<span className="text-red-400">•</span>
|
||||
<span className="font-medium">{name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Certificate cleanup option */}
|
||||
{certificates.length > 0 && (
|
||||
<div className="bg-orange-900/10 border border-orange-800/50 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="delete_certs"
|
||||
name="delete_certs"
|
||||
className="mt-1 w-4 h-4 rounded border-gray-600 text-orange-500 focus:ring-orange-500 focus:ring-offset-0 bg-gray-700"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="delete_certs" className="text-sm text-orange-300 font-medium cursor-pointer">
|
||||
Also delete {certificates.length === 1 ? 'orphaned certificate' : `${certificates.length} orphaned certificates`}
|
||||
</label>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{certificates.length === 1
|
||||
? 'This custom/staging certificate will no longer be used by any hosts.'
|
||||
: 'These custom/staging certificates will no longer be used by any hosts.'}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{certificates.map((cert) => (
|
||||
<li key={cert.id} className="text-xs text-gray-300 flex items-center gap-2">
|
||||
<span className="text-orange-400">→</span>
|
||||
<span className="font-medium">{cert.name || cert.domain}</span>
|
||||
<span className="text-gray-500">({cert.domain})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation buttons */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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, HTMLAttributes } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
title?: string
|
||||
description?: string
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
export function Card({ children, className, title, description, footer, ...props }: CardProps) {
|
||||
return (
|
||||
<div className={clsx('bg-dark-card rounded-lg border border-gray-800 overflow-hidden', className)} {...props}>
|
||||
{(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,67 +0,0 @@
|
||||
import { InputHTMLAttributes, forwardRef, useState } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
errorTestId?: string
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, helperText, errorTestId, className, type, ...props }, ref) => {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const isPassword = type === 'password'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={props.id}
|
||||
className="block text-sm font-medium text-gray-300 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={ref}
|
||||
type={isPassword ? (showPassword ? 'text' : 'password') : type}
|
||||
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',
|
||||
isPassword && 'pr-10',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 focus:outline-none"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-400" data-testid={errorTestId}>{error}</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
interface SwitchProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, onCheckedChange, onChange, id, ...props }, ref) => {
|
||||
return (
|
||||
<label htmlFor={id} className={cn("relative inline-flex items-center cursor-pointer", className)}>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
ref={ref}
|
||||
onChange={(e) => {
|
||||
onChange?.(e)
|
||||
onCheckedChange?.(e.target.checked)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
)
|
||||
Switch.displayName = "Switch"
|
||||
|
||||
export { Switch }
|
||||
Reference in New Issue
Block a user