import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { isAxiosError } from 'axios' import { Shield, ShieldOff, Trash2, Search, AlertTriangle, ExternalLink } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, Link } from 'react-router-dom' import { createBackup } from '../api/backups' import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, type CrowdSecDecision, statusCrowdsec, type CrowdSecStatus, startCrowdsec } from '../api/crowdsec' import { getFeatureFlags } from '../api/featureFlags' import { listCrowdsecPresets, pullCrowdsecPreset, applyCrowdsecPreset, getCrowdsecPresetCache } from '../api/presets' import { getSecurityStatus } from '../api/security' import { CrowdSecBouncerKeyDisplay } from '../components/CrowdSecBouncerKeyDisplay' import { ConfigReloadOverlay } from '../components/LoadingStates' import { Button } from '../components/ui/Button' import { Card } from '../components/ui/Card' import { Input } from '../components/ui/Input' import { CROWDSEC_PRESETS, type CrowdsecPreset } from '../data/crowdsecPresets' import { useConsoleStatus, useEnrollConsole, useClearConsoleEnrollment } from '../hooks/useConsoleEnrollment' import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport' import { toast } from '../utils/toast' export default function CrowdSecConfig() { const { t } = useTranslation() const { data: status, isLoading, error } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus }) const [searchQuery, setSearchQuery] = useState('') const [sortBy, setSortBy] = useState<'alpha' | 'type' | 'source'>('alpha') const [file, setFile] = useState(null) const [selectedPath, setSelectedPath] = useState(null) const [fileContent, setFileContent] = useState(null) const [selectedPresetSlug, setSelectedPresetSlug] = useState('') const [showBanModal, setShowBanModal] = useState(false) const [banForm, setBanForm] = useState({ ip: '', duration: '24h', reason: '' }) const [confirmUnban, setConfirmUnban] = useState(null) const [isApplyingPreset, setIsApplyingPreset] = useState(false) const [presetPreview, setPresetPreview] = useState('') const [presetMeta, setPresetMeta] = useState<{ cacheKey?: string; etag?: string; retrievedAt?: string; source?: string } | null>(null) const [presetStatusMessage, setPresetStatusMessage] = useState(null) const [hubUnavailable, setHubUnavailable] = useState(false) const [validationError, setValidationError] = useState(null) const [applyInfo, setApplyInfo] = useState<{ status?: string; backup?: string; reloadHint?: boolean; usedCscli?: boolean; cacheKey?: string } | null>(null) const queryClient = useQueryClient() const isLocalMode = !!status && status.crowdsec?.mode !== 'disabled' // Note: CrowdSec mode is now controlled via Security Dashboard toggle const { data: featureFlags } = useQuery({ queryKey: ['feature-flags'], queryFn: getFeatureFlags }) const consoleEnrollmentEnabled = Boolean(featureFlags?.['feature.crowdsec.console_enrollment']) const [enrollmentToken, setEnrollmentToken] = useState('') const [consoleTenant, setConsoleTenant] = useState('') const [consoleAgentName, setConsoleAgentName] = useState((typeof window !== 'undefined' && window.location?.hostname) || 'charon-agent') const [consoleAck, setConsoleAck] = useState(false) const [consoleErrors, setConsoleErrors] = useState<{ token?: string; agent?: string; tenant?: string; ack?: string; submit?: string }>({}) const consoleStatusQuery = useConsoleStatus(consoleEnrollmentEnabled) const enrollConsoleMutation = useEnrollConsole() const clearEnrollmentMutation = useClearConsoleEnrollment() const [showReenrollForm, setShowReenrollForm] = useState(false) const navigate = useNavigate() const [initialCheckComplete, setInitialCheckComplete] = useState(false) // Add initial delay to avoid false negative when LAPI is starting useEffect(() => { if (consoleEnrollmentEnabled && !initialCheckComplete) { const timer = setTimeout(() => { setInitialCheckComplete(true) }, 3000) // Wait 3 seconds before first check return () => clearTimeout(timer) } }, [consoleEnrollmentEnabled, initialCheckComplete]) // Add LAPI status check with polling const lapiStatusQuery = useQuery({ queryKey: ['crowdsec-lapi-status'], queryFn: statusCrowdsec, enabled: consoleEnrollmentEnabled && initialCheckComplete, refetchInterval: 5000, // Poll every 5 seconds retry: false, }) const backupMutation = useMutation({ mutationFn: () => createBackup() }) const importMutation = useMutation({ mutationFn: async (file: File) => { return await importCrowdsecConfig(file) }, onSuccess: () => { toast.success('CrowdSec config imported (backup created)') queryClient.invalidateQueries({ queryKey: ['security-status'] }) }, onError: (err: unknown) => { toast.error(err instanceof Error ? err.message : 'Failed to import') } }) const listMutation = useQuery({ queryKey: ['crowdsec-files'], queryFn: listCrowdsecFiles }) const readMutation = useMutation({ mutationFn: (path: string) => readCrowdsecFile(path), onSuccess: (data) => setFileContent(data.content) }) const writeMutation = useMutation({ mutationFn: async ({ path, content }: { path: string; content: string }) => writeCrowdsecFile(path, content), onSuccess: () => { toast.success('File saved'); queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) } }) const presetsQuery = useQuery({ queryKey: ['crowdsec-presets'], queryFn: listCrowdsecPresets, enabled: !!status?.crowdsec, retry: false, }) type PresetCatalogEntry = CrowdsecPreset & { requiresHub: boolean available?: boolean cached?: boolean cacheKey?: string etag?: string retrievedAt?: string source?: string } const presetCatalog: PresetCatalogEntry[] = useMemo(() => { const remotePresets = presetsQuery.data?.presets const localMap = new Map(CROWDSEC_PRESETS.map((preset) => [preset.slug, preset])) if (remotePresets?.length) { return remotePresets.map((preset) => { const local = localMap.get(preset.slug) return { slug: preset.slug, title: preset.title || local?.title || preset.slug, description: local?.description || preset.summary, content: local?.content || '', tags: local?.tags || preset.tags, warning: local?.warning, requiresHub: Boolean(preset.requires_hub), available: preset.available, cached: preset.cached, cacheKey: preset.cache_key, etag: preset.etag, retrievedAt: preset.retrieved_at, source: preset.source, } }) } return CROWDSEC_PRESETS.map((preset) => ({ ...preset, requiresHub: false, available: true, cached: false, source: 'charon-curated' })) }, [presetsQuery.data]) const filteredPresets = useMemo(() => { let result = [...presetCatalog] if (searchQuery) { const query = searchQuery.toLowerCase() result = result.filter( (p) => p.title.toLowerCase().includes(query) || p.description?.toLowerCase().includes(query) || p.slug.toLowerCase().includes(query) ) } result.sort((a, b) => { if (sortBy === 'alpha') { return a.title.localeCompare(b.title) } if (sortBy === 'source') { const sourceA = a.source || 'z' const sourceB = b.source || 'z' if (sourceA !== sourceB) return sourceA.localeCompare(sourceB) return a.title.localeCompare(b.title) } return a.title.localeCompare(b.title) }) return result }, [presetCatalog, searchQuery, sortBy]) useEffect(() => { if (!presetCatalog.length) return if (!selectedPresetSlug || !presetCatalog.some((preset) => preset.slug === selectedPresetSlug)) { setSelectedPresetSlug(presetCatalog[0].slug) } }, [presetCatalog, selectedPresetSlug]) useEffect(() => { if (consoleStatusQuery.data?.agent_name) { setConsoleAgentName((prev) => prev || consoleStatusQuery.data?.agent_name || prev) } if (consoleStatusQuery.data?.tenant) { setConsoleTenant((prev) => prev || consoleStatusQuery.data?.tenant || prev) } }, [consoleStatusQuery.data?.agent_name, consoleStatusQuery.data?.tenant]) const selectedPreset = presetCatalog.find((preset) => preset.slug === selectedPresetSlug) const selectedPresetRequiresHub = selectedPreset?.requiresHub ?? false const pullPresetMutation = useMutation({ mutationFn: (slug: string) => pullCrowdsecPreset(slug), onSuccess: (data) => { setPresetPreview(data.preview) setPresetMeta({ cacheKey: data.cache_key, etag: data.etag, retrievedAt: data.retrieved_at, source: data.source }) setPresetStatusMessage('Preview fetched from hub') setHubUnavailable(false) setValidationError(null) }, onError: (err: unknown) => { setPresetStatusMessage(null) if (isAxiosError(err)) { if (err.response?.status === 400) { setValidationError(err.response.data?.error || 'Preset slug is invalid') return } if (err.response?.status === 503) { setHubUnavailable(true) setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.') return } setPresetStatusMessage(err.response?.data?.error || 'Failed to pull preset preview') } else { setPresetStatusMessage('Failed to pull preset preview') } }, }) useEffect(() => { if (!selectedPreset) return setValidationError(null) setPresetStatusMessage(null) setApplyInfo(null) setPresetMeta({ cacheKey: selectedPreset.cacheKey, etag: selectedPreset.etag, retrievedAt: selectedPreset.retrievedAt, source: selectedPreset.source, }) setPresetPreview(selectedPreset.content || '') pullPresetMutation.mutate(selectedPreset.slug) // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-run when slug changes, not on mutation/preset object identity changes }, [selectedPreset?.slug]) const loadCachedPreview = async () => { if (!selectedPreset) return try { const cached = await getCrowdsecPresetCache(selectedPreset.slug) setPresetPreview(cached.preview) setPresetMeta({ cacheKey: cached.cache_key, etag: cached.etag, retrievedAt: selectedPreset.retrievedAt, source: selectedPreset.source }) setPresetStatusMessage('Loaded cached preview') setHubUnavailable(false) } catch (err) { const msg = isAxiosError(err) ? err.response?.data?.error || err.message : 'Failed to load cached preview' toast.error(msg) } } const normalizedConsoleStatus = consoleStatusQuery.data?.status === 'failed' ? 'degraded' : consoleStatusQuery.data?.status || 'not_enrolled' const isConsoleDegraded = normalizedConsoleStatus === 'degraded' const isConsolePending = enrollConsoleMutation.isPending || normalizedConsoleStatus === 'enrolling' const isConsolePendingAcceptance = normalizedConsoleStatus === 'pending_acceptance' const consoleStatusLabel = normalizedConsoleStatus.replace('_', ' ') const consoleTokenState = consoleStatusQuery.data ? (consoleStatusQuery.data.key_present ? 'Stored (masked)' : 'Not stored') : '—' const canRotateKey = normalizedConsoleStatus === 'enrolled' || normalizedConsoleStatus === 'degraded' || isConsolePendingAcceptance const consoleDocsHref = 'https://wikid82.github.io/charon/security/' const sanitizeSecret = (msg: string) => msg.replace(/\b[A-Za-z0-9]{10,64}\b/g, '***') const sanitizeErrorMessage = (err: unknown) => { if (isAxiosError(err)) { return sanitizeSecret(err.response?.data?.error || err.message) } if (err instanceof Error) return sanitizeSecret(err.message) return 'Console enrollment failed' } const validateConsoleEnrollment = (options?: { allowMissingTenant?: boolean; requireAck?: boolean }) => { const nextErrors: { token?: string; agent?: string; tenant?: string; ack?: string } = {} if (!enrollmentToken.trim()) { nextErrors.token = 'Enrollment token is required' } if (!consoleAgentName.trim()) { nextErrors.agent = 'Agent name is required' } if (!consoleTenant.trim() && !options?.allowMissingTenant) { nextErrors.tenant = 'Tenant / organization is required' } if (options?.requireAck && !consoleAck) { nextErrors.ack = 'You must acknowledge the console data-sharing notice' } setConsoleErrors(nextErrors) return Object.keys(nextErrors).length === 0 } const submitConsoleEnrollment = async (force = false) => { const allowMissingTenant = force && !consoleTenant.trim() const requireAck = normalizedConsoleStatus === 'not_enrolled' if (!validateConsoleEnrollment({ allowMissingTenant, requireAck })) return const tenantValue = consoleTenant.trim() || consoleStatusQuery.data?.tenant || consoleAgentName || 'charon-agent' try { await enrollConsoleMutation.mutateAsync({ enrollment_key: enrollmentToken.trim(), tenant: tenantValue, agent_name: consoleAgentName.trim(), force, }) setConsoleErrors({}) setEnrollmentToken('') setShowReenrollForm(false) if (!consoleTenant.trim()) { setConsoleTenant(tenantValue) } toast.success( force ? 'Enrollment submitted! Accept the request on app.crowdsec.net to complete.' : 'Enrollment request sent! Accept the enrollment on app.crowdsec.net to complete registration.' ) } catch (err) { const message = sanitizeErrorMessage(err) setConsoleErrors((prev) => ({ ...prev, submit: message })) toast.error(message) } } // Banned IPs queries and mutations const decisionsQuery = useQuery({ queryKey: ['crowdsec-decisions'], queryFn: listCrowdsecDecisions, enabled: isLocalMode, }) const banMutation = useMutation({ mutationFn: () => banIP(banForm.ip, banForm.duration, banForm.reason), onSuccess: () => { toast.success(`IP ${banForm.ip} has been banned`) queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] }) setShowBanModal(false) setBanForm({ ip: '', duration: '24h', reason: '' }) }, onError: (err: unknown) => { toast.error(err instanceof Error ? err.message : 'Failed to ban IP') }, }) const unbanMutation = useMutation({ mutationFn: (ip: string) => unbanIP(ip), onSuccess: (_, ip) => { toast.success(`IP ${ip} has been unbanned`) queryClient.invalidateQueries({ queryKey: ['crowdsec-decisions'] }) setConfirmUnban(null) }, onError: (err: unknown) => { toast.error(err instanceof Error ? err.message : 'Failed to unban IP') }, }) const handleExport = async () => { const defaultName = buildCrowdsecExportFilename() const filename = promptCrowdsecFilename(defaultName) if (!filename) return try { const blob = await exportCrowdsecConfig() downloadCrowdsecExport(blob, filename) toast.success('CrowdSec configuration exported') } catch { toast.error('Failed to export CrowdSec configuration') } } const handleImport = async () => { if (!file) return try { await backupMutation.mutateAsync() await importMutation.mutateAsync(file) setFile(null) } catch { // handled in onError } } const handleReadFile = async (path: string) => { setSelectedPath(path) await readMutation.mutateAsync(path) } const handleSaveFile = async () => { if (!selectedPath || fileContent === null) return try { await backupMutation.mutateAsync() await writeMutation.mutateAsync({ path: selectedPath, content: fileContent }) } catch { // handled } } const applyPresetLocally = async (reason?: string) => { if (!selectedPreset) { toast.error('Select a preset to apply') return } const targetPath = selectedPath ?? listMutation.data?.files?.[0] if (!targetPath) { toast.error('Select a configuration file to apply the preset') return } const content = presetPreview || selectedPreset.content if (!content) { toast.error('Preset preview is unavailable; retry pulling before applying') return } try { await backupMutation.mutateAsync() await writeCrowdsecFile(targetPath, content) queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) setSelectedPath(targetPath) setFileContent(content) setApplyInfo({ status: 'applied-locally', cacheKey: presetMeta?.cacheKey }) toast.success(reason ? `${reason}: preset applied locally` : 'Preset applied locally (backup created)') } catch (err) { const msg = err instanceof Error ? err.message : 'Failed to apply preset locally' toast.error(msg) } } const handleApplyPreset = async () => { if (!selectedPreset) { toast.error('Select a preset to apply') return } setIsApplyingPreset(true) setApplyInfo(null) setValidationError(null) try { const res = await applyCrowdsecPreset({ slug: selectedPreset.slug, cache_key: presetMeta?.cacheKey }) setApplyInfo({ status: res.status, backup: res.backup, reloadHint: res.reload_hint, usedCscli: res.used_cscli, cacheKey: res.cache_key, }) const reloadNote = res.reload_hint ? ' (reload required)' : '' toast.success(`Preset applied via backend${reloadNote}`) if (res.backup) { setPresetStatusMessage(`Backup stored at ${res.backup}`) } } catch (err) { if (isAxiosError(err)) { if (err.response?.status === 501) { toast.info('Preset apply is not available on the server; applying locally instead') await applyPresetLocally('Backend apply unavailable') return } if (err.response?.status === 400) { setValidationError(err.response?.data?.error || 'Preset validation failed') toast.error('Preset validation failed') return } if (err.response?.status === 503) { setHubUnavailable(true) setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.') toast.error('Hub unavailable; retry pull/apply or use cached copy') return } const errorMsg = err.response?.data?.error || err.message const backupPath = (err.response?.data as { backup?: string })?.backup // Check if error is due to missing cache if (errorMsg.includes('not cached') || errorMsg.includes('Pull the preset first')) { toast.error(errorMsg) setValidationError('Preset must be pulled before applying. Click "Pull Preview" first.') return } if (backupPath) { setApplyInfo({ status: 'failed', backup: backupPath, cacheKey: presetMeta?.cacheKey }) toast.error(`Apply failed: ${errorMsg}. Backup created at ${backupPath}`) return } toast.error(`Apply failed: ${errorMsg}`) } else { toast.error('Failed to apply preset') } } finally { setIsApplyingPreset(false) } } const presetActionDisabled = !selectedPreset || isApplyingPreset || backupMutation.isPending || pullPresetMutation.isPending || (selectedPresetRequiresHub && hubUnavailable) // Determine if any operation is in progress const isApplyingConfig = importMutation.isPending || writeMutation.isPending || backupMutation.isPending || pullPresetMutation.isPending || isApplyingPreset || banMutation.isPending || unbanMutation.isPending // Determine contextual message const getMessage = () => { if (pullPresetMutation.isPending) { return { message: 'Fetching preset...', submessage: 'Pulling preview from CrowdSec Hub' } } if (isApplyingPreset) { return { message: 'Loading preset...', submessage: 'Applying curated preset with backup' } } if (importMutation.isPending) { return { message: 'Summoning the guardian...', submessage: 'Importing CrowdSec configuration' } } if (writeMutation.isPending) { return { message: 'Guardian inscribes...', submessage: 'Saving configuration file' } } if (banMutation.isPending) { return { message: 'Guardian raises shield...', submessage: 'Banning IP address' } } if (unbanMutation.isPending) { return { message: 'Guardian lowers shield...', submessage: 'Unbanning IP address' } } return { message: 'Strengthening the guard...', submessage: 'Configuration in progress' } } const { message, submessage } = getMessage() if (isLoading) return
{t('crowdsecConfig.loading')}
if (error) return
{t('crowdsecConfig.loadError')}: {(error as Error).message}
if (!status) return
{t('crowdsecConfig.noStatus')}
if (!status.crowdsec) return
{t('crowdsecConfig.noConfig')}
return ( <> {isApplyingConfig && ( )}

{t('crowdsecConfig.title')}

{/* CrowdSec Bouncer API Key - moved from Security Dashboard */} {status.cerberus?.enabled && status.crowdsec.enabled && ( )}

{t('crowdsecConfig.note')}: {t('crowdsecConfig.noteText')}{' '} {t('navigation.security')}.

{consoleEnrollmentEnabled && (

{t('crowdsecConfig.consoleEnrollment.title')}

{t('crowdsecConfig.consoleEnrollment.description')}

{t('crowdsecConfig.consoleEnrollment.privacyNote')} {t('common.docs')}

{t('common.status')}: {consoleStatusLabel}

{t('crowdsecConfig.consoleEnrollment.lastHeartbeat')}: {consoleStatusQuery.data?.last_heartbeat_at ? new Date(consoleStatusQuery.data.last_heartbeat_at).toLocaleString() : '—'}

{consoleStatusQuery.data?.last_error && (

{t('crowdsecConfig.consoleEnrollment.lastError')}: {sanitizeSecret(consoleStatusQuery.data.last_error)}

)} {consoleErrors.submit && (

{consoleErrors.submit}

)} {/* Yellow warning: Process running but LAPI initializing */} {lapiStatusQuery.data && lapiStatusQuery.data.running && !lapiStatusQuery.data.lapi_ready && initialCheckComplete && (

{t('crowdsecConfig.lapiInitializing')}

{t('crowdsecConfig.lapiInitializingDesc')} {lapiStatusQuery.isRefetching && ` ${t('crowdsecConfig.checkingAgain')}`}

)} {/* Red warning: Process not running at all */} {lapiStatusQuery.data && !lapiStatusQuery.data.running && initialCheckComplete && (

{t('crowdsecConfig.notRunning')}

{t('crowdsecConfig.notRunningDesc')}

)}
setEnrollmentToken(e.target.value)} placeholder={t('crowdsecConfig.consoleEnrollment.tokenPlaceholder')} helperText={t('crowdsecConfig.consoleEnrollment.tokenHelper')} error={consoleErrors.token} errorTestId="console-enroll-error" data-testid="console-enrollment-token" /> setConsoleAgentName(e.target.value)} error={consoleErrors.agent} errorTestId="console-enroll-error" data-testid="console-agent-name" /> setConsoleTenant(e.target.value)} helperText={t('crowdsecConfig.consoleEnrollment.tenantHelper')} error={consoleErrors.tenant} errorTestId="console-enroll-error" data-testid="console-tenant" />
setConsoleAck(e.target.checked)} disabled={isConsolePending} data-testid="console-ack-checkbox" />
{consoleErrors.ack &&

{consoleErrors.ack}

}
{isConsoleDegraded && ( )}
{/* Info box for pending acceptance status */} {isConsolePendingAcceptance && (

{t('crowdsecConfig.consoleEnrollment.actionRequired')}: {t('crowdsecConfig.consoleEnrollment.pendingAcceptanceText')}{' '} app.crowdsec.net . {t('crowdsecConfig.consoleEnrollment.pendingAcceptanceNote')}

)} {/* Re-enrollment Section - shown when enrolled or pending */} {(normalizedConsoleStatus === 'enrolled' || normalizedConsoleStatus === 'pending_acceptance') && (

{t('crowdsecConfig.reenroll.title')}

{t('crowdsecConfig.reenroll.description')}

{!showReenrollForm ? (
{t('crowdsecConfig.reenroll.getNewKey')}
) : (
{/* Re-enrollment form */}
setEnrollmentToken(e.target.value)} placeholder={t('crowdsecConfig.reenroll.keyPlaceholder')} data-testid="reenroll-token-input" />
setConsoleAgentName(e.target.value)} placeholder={t('crowdsecConfig.reenroll.agentPlaceholder')} />
setConsoleTenant(e.target.value)} placeholder={t('crowdsecConfig.reenroll.orgPlaceholder')} />
)} {/* Clear enrollment option */}
)}

{t('crowdsecConfig.consoleEnrollment.agent')}

{consoleStatusQuery.data?.agent_name || consoleAgentName || '—'}

{t('crowdsecConfig.consoleEnrollment.tenantLabel')}

{consoleStatusQuery.data?.tenant || consoleTenant || '—'}

{t('crowdsecConfig.consoleEnrollment.enrollmentToken')}

{consoleTokenState}

{t('crowdsecConfig.consoleEnrollment.lastAttempt')}: {consoleStatusQuery.data?.last_attempt_at ? new Date(consoleStatusQuery.data.last_attempt_at).toLocaleString() : '—'} {t('crowdsecConfig.consoleEnrollment.enrolledAt')}: {consoleStatusQuery.data?.enrolled_at ? new Date(consoleStatusQuery.data.enrolled_at).toLocaleString() : '—'} {consoleStatusQuery.data?.correlation_id && {t('crowdsecConfig.consoleEnrollment.correlationId')}: {consoleStatusQuery.data.correlation_id}}
)}

{t('crowdsecConfig.packages.title')}

{t('crowdsecConfig.packages.description')}

setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" aria-label={t('crowdsecConfig.packages.selectFile') || "Select CrowdSec package"} />

{t('crowdsecConfig.presets.title')}

{t('crowdsecConfig.presets.description')}

setSearchQuery(e.target.value)} className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-9 pr-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500" />
{filteredPresets.length > 0 ? ( filteredPresets.map((preset) => (
setSelectedPresetSlug(preset.slug)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() setSelectedPresetSlug(preset.slug) } }} aria-pressed={selectedPresetSlug === preset.slug} className={`p-3 cursor-pointer hover:bg-gray-800 border-b border-gray-800 last:border-0 outline-none focus-visible:bg-gray-800 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 ${ selectedPresetSlug === preset.slug ? 'bg-blue-900/20 border-l-2 border-l-blue-500' : 'border-l-2 border-l-transparent' }`} >
{preset.title} {preset.source && ( {preset.source === 'charon-curated' ? t('crowdsecConfig.presets.curated') : t('crowdsecConfig.presets.hub')} )}

{preset.description}

)) ) : (
{t('crowdsecConfig.presets.noPresets', { query: searchQuery })}
)}
{validationError && (

{validationError}

)} {presetStatusMessage && (

{presetStatusMessage}

)} {hubUnavailable && (
{t('crowdsecConfig.presets.hubUnreachable')} {selectedPreset?.cached && ( )}
)} {selectedPreset && (

{selectedPreset.title}

{selectedPreset.description}

{selectedPreset.warning && (

{selectedPreset.warning}

)}

{t('crowdsecConfig.presets.targetFile')}: {selectedPath ?? t('crowdsecConfig.presets.selectFileBelow')}

{presetMeta && (
{t('crowdsecConfig.presets.cacheKey')}: {presetMeta.cacheKey || '—'} {t('crowdsecConfig.presets.etag')}: {presetMeta.etag || '—'} {t('crowdsecConfig.presets.source')}: {presetMeta.source || selectedPreset.source || '—'} {t('crowdsecConfig.presets.fetched')}: {presetMeta.retrievedAt ? new Date(presetMeta.retrievedAt).toLocaleString() : '—'}
)}

{t('crowdsecConfig.presets.previewYaml')}

                  {presetPreview || selectedPreset.content || t('crowdsecConfig.presets.previewUnavailable')}
                
{applyInfo && (

{t('common.status')}: {applyInfo.status || t('crowdsecConfig.presets.applied')}

{applyInfo.backup &&

{t('crowdsecConfig.presets.backup')}: {applyInfo.backup}

} {applyInfo.reloadHint &&

{t('crowdsecConfig.presets.reload')}: {t('crowdsecConfig.presets.required')}

} {applyInfo.usedCscli !== undefined &&

{t('crowdsecConfig.presets.method')}: {applyInfo.usedCscli ? 'cscli' : t('crowdsecConfig.presets.filesystem')}

}
)}
{selectedPreset.cached && ( )} {selectedPresetRequiresHub && hubUnavailable && ( {t('crowdsecConfig.presets.applyDisabled')} )}
)} {presetCatalog.length === 0 && (

{t('crowdsecConfig.presets.noPresets')}

)}

{t('crowdsecConfig.files.title')}