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:
GitHub Actions
2026-01-04 20:04:22 +00:00
parent d0cc2ada3c
commit 7fa07328c5
20 changed files with 6033 additions and 3 deletions
@@ -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>
)
}
+92 -3
View File
@@ -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()
})
})