feat: implement DNS provider detection and related components
- Add `detectDNSProvider` and `getDetectionPatterns` functions in `dnsDetection.ts` for API interaction. - Create `DNSDetectionResult` component to display detection results and suggested providers. - Integrate DNS detection in `ProxyHostForm` with automatic detection for wildcard domains. - Implement hooks for DNS detection: `useDetectDNSProvider`, `useCachedDetectionResult`, and `useDetectionPatterns`. - Add tests for DNS detection functionality and components. - Update translations for DNS detection messages.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import { CheckCircle2, AlertCircle, Info } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge, Button, Alert } from './ui'
|
||||
import type { DetectionResult } from '../api/dnsDetection'
|
||||
import type { DNSProvider } from '../api/dnsProviders'
|
||||
|
||||
interface DNSDetectionResultProps {
|
||||
result: DetectionResult
|
||||
onUseSuggested?: (provider: DNSProvider) => void
|
||||
onSelectManually?: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function DNSDetectionResult({
|
||||
result,
|
||||
onUseSuggested,
|
||||
onSelectManually,
|
||||
isLoading = false,
|
||||
}: DNSDetectionResultProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<Info className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm font-medium">{t('dns_detection.detecting')}</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm font-medium">{t('dns_detection.error', { error: result.error })}</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (!result.detected) {
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<Info className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm font-medium">{t('dns_detection.not_detected')}</p>
|
||||
{result.nameservers.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-content-secondary">{t('dns_detection.nameservers')}:</p>
|
||||
<ul className="text-xs text-content-secondary mt-1 space-y-0.5">
|
||||
{result.nameservers.map((ns, i) => (
|
||||
<li key={i} className="font-mono">
|
||||
{ns}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const getConfidenceBadgeVariant = (confidence: string) => {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'success'
|
||||
case 'medium':
|
||||
return 'warning'
|
||||
case 'low':
|
||||
return 'outline'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
const getConfidenceLabel = (confidence: string) => {
|
||||
return t(`dns_detection.confidence_${confidence}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="success" className="border-brand-500/30 bg-brand-500/5">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-500" />
|
||||
<div className="ml-2 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium">
|
||||
{t('dns_detection.detected', { provider: result.provider_type })}
|
||||
</p>
|
||||
<Badge variant={getConfidenceBadgeVariant(result.confidence)} size="sm">
|
||||
{getConfidenceLabel(result.confidence)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{result.suggested_provider && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => onUseSuggested?.(result.suggested_provider!)}
|
||||
>
|
||||
{t('dns_detection.use_suggested', { provider: result.suggested_provider.name })}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onSelectManually}>
|
||||
{t('dns_detection.select_manually')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.nameservers.length > 0 && (
|
||||
<details className="mt-3">
|
||||
<summary className="text-xs text-content-secondary cursor-pointer hover:text-content-primary">
|
||||
{t('dns_detection.nameservers')} ({result.nameservers.length})
|
||||
</summary>
|
||||
<ul className="text-xs text-content-secondary mt-2 space-y-0.5 ml-4">
|
||||
{result.nameservers.map((ns, i) => (
|
||||
<li key={i} className="font-mono">
|
||||
{ns}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { CircleHelp, AlertCircle, Check, X, Loader2, Copy, Info, AlertTriangle } from 'lucide-react'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import type { ProxyHost, ApplicationPreset } from '../api/proxyHosts'
|
||||
@@ -15,6 +15,9 @@ import { parse } from 'tldts'
|
||||
import { Alert } from './ui/Alert'
|
||||
import { isLikelyDockerContainerIP, isPrivateOrDockerIP } from '../utils/validation'
|
||||
import DNSProviderSelector from './DNSProviderSelector'
|
||||
import { useDetectDNSProvider } from '../hooks/useDNSDetection'
|
||||
import { DNSDetectionResult } from './DNSDetectionResult'
|
||||
import type { DNSProvider } from '../api/dnsProviders'
|
||||
|
||||
// Application preset configurations
|
||||
const APPLICATION_PRESETS: { value: ApplicationPreset; label: string; description: string }[] = [
|
||||
@@ -119,6 +122,11 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
const [charonInternalIP, setCharonInternalIP] = useState<string>('')
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||
|
||||
// DNS auto-detection state
|
||||
const { mutateAsync: detectProvider, isPending: isDetecting, data: detectionResult, reset: resetDetection } = useDetectDNSProvider()
|
||||
const [manualProviderSelection, setManualProviderSelection] = useState(false)
|
||||
const detectionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Fetch Charon internal IP on mount (legacy: CPMP internal IP)
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/health')
|
||||
@@ -131,6 +139,72 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Auto-detect DNS provider when wildcard domain is entered (debounced 500ms)
|
||||
useEffect(() => {
|
||||
// Clear any pending detection
|
||||
if (detectionTimeoutRef.current) {
|
||||
clearTimeout(detectionTimeoutRef.current)
|
||||
detectionTimeoutRef.current = null
|
||||
}
|
||||
|
||||
// Reset detection if domain is cleared or manual selection is active
|
||||
if (!formData.domain_names || manualProviderSelection) {
|
||||
resetDetection()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if domain contains wildcard
|
||||
const domains = formData.domain_names.split(',').map(d => d.trim())
|
||||
const wildcardDomain = domains.find(d => d.startsWith('*'))
|
||||
|
||||
if (!wildcardDomain) {
|
||||
resetDetection()
|
||||
return
|
||||
}
|
||||
|
||||
// Extract base domain from wildcard (*.example.com -> example.com)
|
||||
const baseDomain = wildcardDomain.replace(/^\*\./, '')
|
||||
|
||||
// Don't detect if provider already set (unless detection succeeded before)
|
||||
if (formData.dns_provider_id && !detectionResult?.suggested_provider) {
|
||||
return
|
||||
}
|
||||
|
||||
// Debounce detection call by 500ms
|
||||
detectionTimeoutRef.current = setTimeout(() => {
|
||||
detectProvider(baseDomain).catch(err => {
|
||||
console.error('DNS detection failed:', err)
|
||||
})
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
if (detectionTimeoutRef.current) {
|
||||
clearTimeout(detectionTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [formData.domain_names, formData.dns_provider_id, detectProvider, resetDetection, detectionResult, manualProviderSelection])
|
||||
|
||||
// Auto-select suggested provider if confidence is high
|
||||
useEffect(() => {
|
||||
if (detectionResult?.suggested_provider && detectionResult.confidence === 'high' && !manualProviderSelection && !formData.dns_provider_id) {
|
||||
setFormData(prev => ({ ...prev, dns_provider_id: detectionResult.suggested_provider!.id }))
|
||||
toast.success(`Auto-selected: ${detectionResult.suggested_provider.name}`)
|
||||
}
|
||||
}, [detectionResult, manualProviderSelection, formData.dns_provider_id])
|
||||
|
||||
// Handle using suggested provider
|
||||
const handleUseSuggested = useCallback((provider: DNSProvider) => {
|
||||
setFormData(prev => ({ ...prev, dns_provider_id: provider.id }))
|
||||
setManualProviderSelection(false)
|
||||
toast.success(`Selected: ${provider.name}`)
|
||||
}, [])
|
||||
|
||||
// Handle manual provider selection
|
||||
const handleManualSelection = useCallback(() => {
|
||||
setManualProviderSelection(true)
|
||||
}, [])
|
||||
|
||||
|
||||
// Auto-detect application preset from Docker image
|
||||
const detectApplicationPreset = (imageName: string): ApplicationPreset => {
|
||||
const lowerImage = imageName.toLowerCase()
|
||||
@@ -658,7 +732,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
|
||||
{/* DNS Provider Selector for Wildcard Domains */}
|
||||
{hasWildcardDomain && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<Alert variant="info">
|
||||
<Info className="h-4 w-4" />
|
||||
<div>
|
||||
@@ -670,9 +744,24 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
{/* DNS Detection Result */}
|
||||
{(isDetecting || detectionResult) && !manualProviderSelection && (
|
||||
<DNSDetectionResult
|
||||
result={detectionResult!}
|
||||
isLoading={isDetecting}
|
||||
onUseSuggested={handleUseSuggested}
|
||||
onSelectManually={handleManualSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DNSProviderSelector
|
||||
value={formData.dns_provider_id ?? undefined}
|
||||
onChange={(id) => setFormData(prev => ({ ...prev, dns_provider_id: id ?? null }))}
|
||||
onChange={(id) => {
|
||||
setFormData(prev => ({ ...prev, dns_provider_id: id ?? null }))
|
||||
if (id) {
|
||||
setManualProviderSelection(true)
|
||||
}
|
||||
}}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DNSDetectionResult } from '../DNSDetectionResult'
|
||||
import type { DetectionResult } from '../../api/dnsDetection'
|
||||
import type { DNSProvider } from '../../api/dnsProviders'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
'dns_detection.detecting': 'Detecting DNS provider...',
|
||||
'dns_detection.detected': `${params?.provider} detected`,
|
||||
'dns_detection.confidence_high': 'High confidence',
|
||||
'dns_detection.confidence_medium': 'Medium confidence',
|
||||
'dns_detection.confidence_low': 'Low confidence',
|
||||
'dns_detection.confidence_none': 'No match',
|
||||
'dns_detection.not_detected': 'Could not detect DNS provider',
|
||||
'dns_detection.use_suggested': `Use ${params?.provider}`,
|
||||
'dns_detection.select_manually': 'Select manually',
|
||||
'dns_detection.nameservers': 'Nameservers',
|
||||
'dns_detection.error': `Detection failed: ${params?.error}`,
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DNSDetectionResult', () => {
|
||||
const mockSuggestedProvider: DNSProvider = {
|
||||
id: 1,
|
||||
uuid: 'test-uuid',
|
||||
name: 'Production Cloudflare',
|
||||
provider_type: 'cloudflare',
|
||||
enabled: true,
|
||||
is_default: true,
|
||||
has_credentials: true,
|
||||
propagation_timeout: 120,
|
||||
polling_interval: 5,
|
||||
success_count: 10,
|
||||
failure_count: 0,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
it('should show loading state', () => {
|
||||
render(
|
||||
<DNSDetectionResult
|
||||
result={{} as DetectionResult}
|
||||
isLoading={true}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Detecting DNS provider...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error message', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: false,
|
||||
nameservers: [],
|
||||
confidence: 'none',
|
||||
error: 'Network error',
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.getByText(/Detection failed: Network error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show not detected message with nameservers', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: false,
|
||||
nameservers: ['ns1.unknown.com', 'ns2.unknown.com'],
|
||||
confidence: 'none',
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.getByText('Could not detect DNS provider')).toBeInTheDocument()
|
||||
expect(screen.getByText(/nameservers/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('ns1.unknown.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('ns2.unknown.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show successful detection with high confidence', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'cloudflare',
|
||||
nameservers: ['ns1.cloudflare.com', 'ns2.cloudflare.com'],
|
||||
confidence: 'high',
|
||||
suggested_provider: mockSuggestedProvider,
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.getByText('cloudflare detected')).toBeInTheDocument()
|
||||
expect(screen.getByText('High confidence')).toBeInTheDocument()
|
||||
expect(screen.getByText('Use Production Cloudflare')).toBeInTheDocument()
|
||||
expect(screen.getByText('Select manually')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onUseSuggested when "Use" button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUseSuggested = vi.fn()
|
||||
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'cloudflare',
|
||||
nameservers: ['ns1.cloudflare.com'],
|
||||
confidence: 'high',
|
||||
suggested_provider: mockSuggestedProvider,
|
||||
}
|
||||
|
||||
render(
|
||||
<DNSDetectionResult
|
||||
result={result}
|
||||
onUseSuggested={onUseSuggested}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Use Production Cloudflare'))
|
||||
|
||||
expect(onUseSuggested).toHaveBeenCalledWith(mockSuggestedProvider)
|
||||
})
|
||||
|
||||
it('should call onSelectManually when "Select manually" button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelectManually = vi.fn()
|
||||
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'cloudflare',
|
||||
nameservers: ['ns1.cloudflare.com'],
|
||||
confidence: 'high',
|
||||
suggested_provider: mockSuggestedProvider,
|
||||
}
|
||||
|
||||
render(
|
||||
<DNSDetectionResult
|
||||
result={result}
|
||||
onSelectManually={onSelectManually}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Select manually'))
|
||||
|
||||
expect(onSelectManually).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show medium confidence badge', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'route53',
|
||||
nameservers: ['ns-123.awsdns-12.com'],
|
||||
confidence: 'medium',
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.getByText('Medium confidence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show low confidence badge', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'digitalocean',
|
||||
nameservers: ['ns1.digitalocean.com'],
|
||||
confidence: 'low',
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.getByText('Low confidence')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show expandable nameservers list', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'cloudflare',
|
||||
nameservers: ['ns1.cloudflare.com', 'ns2.cloudflare.com', 'ns3.cloudflare.com'],
|
||||
confidence: 'high',
|
||||
suggested_provider: mockSuggestedProvider,
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
// Nameservers are in a details element
|
||||
const summary = screen.getByText(/Nameservers \(3\)/)
|
||||
await user.click(summary)
|
||||
|
||||
expect(screen.getByText('ns1.cloudflare.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('ns2.cloudflare.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('ns3.cloudflare.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show action buttons when no suggested provider', () => {
|
||||
const result: DetectionResult = {
|
||||
domain: 'example.com',
|
||||
detected: true,
|
||||
provider_type: 'cloudflare',
|
||||
nameservers: ['ns1.cloudflare.com'],
|
||||
confidence: 'high',
|
||||
}
|
||||
|
||||
render(<DNSDetectionResult result={result} />)
|
||||
|
||||
expect(screen.queryByText(/Use/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Select manually')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user