Files
wild-central/web/src/components/CrowdSecComponent.tsx
Paul Payne 735f3fcb05 feat: Add Wild Central web app (Central-only UI)
Extract Central pages from the wild-cloud web app into a standalone
React app for Wild Central. Includes:

- Central overview, DNS, DHCP, Firewall, VPN, Ingress, CrowdSec pages
- Simplified sidebar with Central-only navigation
- Branding updated to "Wild Central"
- All Cloud-specific pages, components, hooks, and API services removed
- TypeScript type-check and production build pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 04:10:43 +00:00

761 lines
36 KiB
TypeScript

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Alert, AlertDescription } from './ui/alert';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import {
ShieldAlert, Loader2, AlertCircle, CheckCircle, Ban, X, Trash2,
BookOpen, Shield, Activity, Zap, RefreshCw, Globe,
} from 'lucide-react';
import { Badge } from './ui/badge';
// Instance provisioning removed — Central-only mode
import { useCrowdSec } from '../hooks/useCrowdSec';
import type { AddDecisionRequest, CrowdSecAlert } from '../services/api/networking';
type Timescale = '1h' | '6h' | '24h' | '7d';
const TIMESCALES: { key: Timescale; label: string; ms: number; bucketMs: number }[] = [
{ key: '1h', label: '1h', ms: 60 * 60 * 1000, bucketMs: 5 * 60 * 1000 },
{ key: '6h', label: '6h', ms: 6 * 60 * 60 * 1000, bucketMs: 30 * 60 * 1000 },
{ key: '24h', label: '24h', ms: 24 * 60 * 60 * 1000, bucketMs: 2 * 60 * 60 * 1000 },
{ key: '7d', label: '7d', ms: 7 * 24 * 60 * 60 * 1000, bucketMs: 24 * 60 * 60 * 1000 },
];
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
function formatBucketLabel(ts: number, scale: Timescale): string {
const d = new Date(ts);
if (scale === '7d') return d.toLocaleDateString(undefined, { timeZone: tz, weekday: 'short', month: 'short', day: 'numeric' });
return d.toLocaleTimeString(undefined, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: false });
}
function binAlerts(alerts: CrowdSecAlert[], scale: Timescale) {
const cfg = TIMESCALES.find(t => t.key === scale)!;
const now = Date.now();
// Align start to a clean bucket boundary so labels fall on round times
const rawStart = now - cfg.ms;
const alignedStart = Math.floor(rawStart / cfg.bucketMs) * cfg.bucketMs;
const numBuckets = Math.ceil((now - alignedStart) / cfg.bucketMs);
const buckets = Array.from({ length: numBuckets }, (_, i) => ({
label: formatBucketLabel(alignedStart + i * cfg.bucketMs, scale),
detections: 0,
}));
for (const a of alerts) {
const t = new Date(a.createdAt).getTime();
if (t < alignedStart) continue;
const idx = Math.floor((t - alignedStart) / cfg.bucketMs);
if (idx >= 0 && idx < numBuckets) buckets[idx].detections++;
}
return buckets;
}
function formatRelativeTime(isoString?: string): string {
if (!isoString) return 'unknown';
const diff = Date.now() - new Date(isoString).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function scenarioHubUrl(scenario: string): string | null {
if (!scenario) return null;
const parts = scenario.split('/');
// Only link hub scenarios in author/name format (e.g. crowdsecurity/http-probing-classb)
// CAPI category labels (http:scan, ssh:bruteforce) have no individual hub pages
if (parts.length === 2 && parts[0] !== 'custom') {
return `https://app.crowdsec.net/hub/author/${parts[0]}/configurations/name/${parts[1]}`;
}
return null;
}
const KNOWN_ABBREV = new Set(['ssh', 'http', 'https', 'tcp', 'udp', 'ftp', 'ip', 'dns', 'tls', 'dos', 'ddos', 'bf']);
function titleCaseScenario(s: string): string {
return s.replace(/[:\-_]/g, ' ').split(' ').filter(Boolean).map(w =>
KNOWN_ABBREV.has(w.toLowerCase()) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
).join(' ');
}
function friendlyReason(reason: string): string {
if (!reason) return 'Community blocklist';
const map: Record<string, string> = {
'crowdsecurity/http-scan-classb': 'HTTP Scanner',
'crowdsecurity/ssh-slow-bruteforce': 'SSH Brute Force (slow)',
'crowdsecurity/ssh-bf': 'SSH Brute Force',
'crowdsecurity/ssh-bruteforce': 'SSH Brute Force',
'crowdsecurity/http-dos': 'HTTP DoS',
'crowdsecurity/http-bruteforce': 'HTTP Brute Force',
'crowdsecurity/http-exploit': 'HTTP Exploit',
'crowdsecurity/http-crawl': 'HTTP Crawler',
'crowdsecurity/http-bad-user-agent': 'Bad User Agent',
'crowdsecurity/iptables-scan-multi_ports': 'Port Scanner',
};
if (map[reason]) return map[reason];
const short = reason.includes('/') ? reason.split('/').slice(1).join(' ') : reason;
return titleCaseScenario(short);
}
function parseDuration(dur?: string): string {
if (!dur) return '';
// Go duration: "164h1m31s" → "6d 20h"
const h = dur.match(/(\d+)h/);
const m = dur.match(/(\d+)m/);
const hours = h ? parseInt(h[1]) : 0;
const mins = m ? parseInt(m[1]) : 0;
if (hours >= 24) {
const days = Math.floor(hours / 24);
const rem = hours % 24;
return rem > 0 ? `${days}d ${rem}h` : `${days}d`;
}
if (hours > 0) return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
return `${mins}m`;
}
interface BlockFormValues {
ip: string;
type: 'ban' | 'allow';
duration: string;
reason: string;
}
export function CrowdSecComponent() {
const instances: string[] = [];
const {
status,
isLoadingStatus,
summary,
isLoadingSummary,
alerts,
isLoadingAlerts,
refetchAlerts,
graphAlerts,
isLoadingGraphAlerts,
decisions,
addDecision,
isAddingDecision,
addDecisionError,
deleteDecision,
deleteBouncer,
deleteMachine,
provision,
isProvisioning,
} = useCrowdSec();
const [provisionResults, setProvisionResults] = useState<Record<string, { ok: boolean; message: string }>>({});
const [provisioningInstance, setProvisioningInstance] = useState<string | null>(null);
const [blockSuccess, setBlockSuccess] = useState<string | null>(null);
const [timescale, setTimescale] = useState<Timescale>('24h');
const { register, handleSubmit, reset, setValue, watch, formState: { errors } } = useForm<BlockFormValues>({
defaultValues: { ip: '', type: 'ban', duration: '24h', reason: 'manual' },
});
const selectedType = watch('type');
function handleProvision(instanceName: string) {
setProvisioningInstance(instanceName);
provision(instanceName, {
onSuccess: (data) => {
setProvisionResults((prev) => ({ ...prev, [instanceName]: { ok: true, message: data.message } }));
setProvisioningInstance(null);
},
onError: (err) => {
setProvisionResults((prev) => ({ ...prev, [instanceName]: { ok: false, message: (err as Error).message } }));
setProvisioningInstance(null);
},
});
}
function onBlockSubmit(values: BlockFormValues) {
setBlockSuccess(null);
const req: AddDecisionRequest = { ip: values.ip, type: values.type, reason: values.reason, duration: values.duration };
addDecision(req, {
onSuccess: (data) => {
setBlockSuccess(data.message);
reset();
},
});
}
const sortedReasons = summary
? Object.entries(summary.byReason).sort(([, a], [, b]) => b - a)
: [];
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<ShieldAlert className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">CrowdSec</h2>
<p className="text-muted-foreground">Collaborative intrusion detection protecting your infrastructure with community threat intelligence</p>
</div>
</div>
{/* Educational card */}
<Card className="bg-gradient-to-br from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20 border-cyan-200 dark:border-cyan-800">
<CardContent className="pt-4">
<div className="flex gap-3">
<BookOpen className="h-5 w-5 text-cyan-600 dark:text-cyan-400 shrink-0 mt-0.5" />
<div className="space-y-1 text-sm text-cyan-900 dark:text-cyan-100">
<p>
<strong>CrowdSec</strong> analyzes logs from your k8s clusters, detects attacks, and shares threat
intelligence with the community. Wild Central runs the <strong>LAPI</strong> the hub that agents
report to and that bouncers query for the live blocklist. Your cluster contributes to and benefits
from a shared blocklist of millions of known malicious IPs.
</p>
</div>
</div>
</CardContent>
</Card>
{/* LAPI Status */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<CardTitle>LAPI Status</CardTitle>
</div>
{isLoadingStatus ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<Badge variant={status?.active ? 'success' : 'warning'} className="gap-1">
{status?.active && <CheckCircle className="h-3 w-3" />}
{status?.active ? 'Running' : 'Stopped'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
{!status?.active && !isLoadingStatus && (
<p className="text-sm text-muted-foreground">
CrowdSec is not running on Wild Central. Install it with{' '}
<code className="font-mono text-xs bg-muted px-1 rounded">apt install crowdsec crowdsec-firewall-bouncer</code>
{' '}or reinstall wild-cloud-central.
</p>
)}
{status?.active && (
<>
<div className="flex items-center justify-between py-2 px-3 rounded bg-muted">
<div className="flex items-center gap-2 text-sm">
<Shield className="h-4 w-4 text-muted-foreground" />
<span>Perimeter firewall enforcement</span>
<span className="text-xs text-muted-foreground">(nftables on Wild Central)</span>
</div>
<Badge variant={status.firewallBouncer ? 'success' : 'warning'} className="gap-1">
{status.firewallBouncer && <CheckCircle className="h-3 w-3" />}
{status.firewallBouncer ? 'Active' : 'Stopped'}
</Badge>
</div>
{!status.firewallBouncer && (
<p className="text-xs text-muted-foreground">
Install the firewall bouncer to block threats at the perimeter:{' '}
<code className="font-mono bg-muted px-1 rounded">apt install crowdsec-firewall-bouncer</code>
</p>
)}
<p className="text-xs text-muted-foreground">
{status.machines.length > 0 && `${status.machines.length} k8s agent${status.machines.length !== 1 ? 's' : ''} reporting. `}
{status.bouncers.length > 0 && `${status.bouncers.length} enforcement point${status.bouncers.length !== 1 ? 's' : ''} active.`}
</p>
</>
)}
</CardContent>
</Card>
{status?.active && (
<>
{/* Active Protection Summary */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<CardTitle>Active Protection</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
IPs currently blocked by the community blocklist (CAPI) and local decisions, enforced by your bouncers.
</p>
{isLoadingSummary ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />Loading ban statistics
</div>
) : summary ? (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Ban className="h-5 w-5 text-red-500" />
<span className="text-2xl font-bold">{summary.total.toLocaleString()}</span>
</div>
<span className="text-sm text-muted-foreground">IPs blocked right now</span>
</div>
{sortedReasons.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">By threat type</p>
{sortedReasons.slice(0, 8).map(([reason, count]) => {
const pct = Math.round((count / summary.total) * 100);
return (
<div key={reason} className="flex items-center gap-2 text-sm">
<div className="flex-1 flex items-center gap-2">
<div
className="h-1.5 bg-red-400 rounded"
style={{ width: `${Math.max(pct, 2)}%`, maxWidth: '60%' }}
/>
{scenarioHubUrl(reason) ? (
<a href={scenarioHubUrl(reason)!} target="_blank" rel="noopener noreferrer" className="text-muted-foreground hover:text-foreground hover:underline">
{friendlyReason(reason)}
</a>
) : (
<span className="text-muted-foreground">{friendlyReason(reason)}</span>
)}
</div>
<span className="font-mono text-xs tabular-nums">{count.toLocaleString()}</span>
<span className="text-xs text-muted-foreground w-8 text-right">{pct}%</span>
</div>
);
})}
{sortedReasons.length > 8 && (
<p className="text-xs text-muted-foreground">+{sortedReasons.length - 8} more categories</p>
)}
</div>
)}
</div>
) : null}
</CardContent>
</Card>
{/* Detections Over Time */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<CardTitle>Detections Over Time</CardTitle>
</div>
<div className="flex gap-1">
{TIMESCALES.map(t => (
<Button
key={t.key}
variant={timescale === t.key ? 'default' : 'ghost'}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setTimescale(t.key)}
>
{t.label}
</Button>
))}
</div>
</div>
</CardHeader>
<CardContent>
{isLoadingGraphAlerts ? (
<div className="flex items-center justify-center h-40 gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />Loading
</div>
) : (
<ResponsiveContainer width="100%" height={160}>
<BarChart data={binAlerts(graphAlerts, timescale)} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval="preserveStartEnd" className="fill-muted-foreground" />
<YAxis allowDecimals={false} tick={{ fontSize: 10 }} className="fill-muted-foreground" />
<Tooltip
contentStyle={{ fontSize: 12 }}
formatter={(v) => [v, 'detections']}
/>
<Bar dataKey="detections" fill="#ef4444" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Recent Alert Feed */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<CardTitle>Recent Detections</CardTitle>
</div>
<Button variant="ghost" size="sm" onClick={() => refetchAlerts()} className="h-7 gap-1.5 text-xs">
<RefreshCw className="h-3 w-3" />Refresh
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-3">
Attack events detected by your k8s agents and reported to this LAPI. Each event results in a ban
that is shared with the CrowdSec community.
</p>
{isLoadingAlerts ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />Loading
</div>
) : alerts.length === 0 ? (
<div className="text-center py-6">
<Shield className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No detections yet.</p>
<p className="text-xs text-muted-foreground mt-1">
Events appear here when k8s agents detect and report attacks. The community blocklist
(CAPI) is already protecting you even before local detections.
</p>
</div>
) : (
<div className="space-y-1 max-h-72 overflow-y-auto">
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-3 text-xs text-muted-foreground font-medium px-2 pb-1">
<span>Source IP</span>
<span>Threat</span>
<span>Agent</span>
<span>When</span>
</div>
{alerts.map((alert) => (
<div key={alert.id} className="grid grid-cols-[1fr_auto_auto_auto] gap-x-3 items-center py-1.5 px-2 bg-muted rounded text-sm">
<div className="flex items-center gap-1.5 min-w-0">
<span className="font-mono text-xs truncate">{alert.sourceIP || '—'}</span>
{alert.country && (
<span className="text-xs text-muted-foreground flex items-center gap-0.5 shrink-0">
<Globe className="h-3 w-3" />{alert.country}
</span>
)}
</div>
{scenarioHubUrl(alert.scenario) ? (
<a href={scenarioHubUrl(alert.scenario)!} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground whitespace-nowrap hover:text-foreground hover:underline">
{friendlyReason(alert.scenario)}
</a>
) : (
<span className="text-xs text-muted-foreground whitespace-nowrap">{friendlyReason(alert.scenario)}</span>
)}
<span className="text-xs font-mono text-muted-foreground whitespace-nowrap">{alert.machineId?.replace(/^wc-/, '') ?? '—'}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">{formatRelativeTime(alert.createdAt)}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Manual Decisions: Block / Allow */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Ban className="h-4 w-4 text-muted-foreground" />
<CardTitle>Block or Allow an IP</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Manually ban an IP (e.g. an attacker you've identified) or add a local allowlist entry
to prevent a legitimate IP from being blocked by the community list.
</p>
<form onSubmit={handleSubmit(onBlockSubmit)} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="ip">IP Address</Label>
<Input
id="ip"
placeholder="1.2.3.4"
className="mt-1 font-mono"
{...register('ip', {
required: 'Required',
pattern: { value: /^[\d.:/a-fA-F]+$/, message: 'Invalid IP' },
})}
/>
{errors.ip && <p className="text-xs text-red-600 mt-1">{errors.ip.message}</p>}
</div>
<div>
<Label htmlFor="reason">Reason</Label>
<Input
id="reason"
placeholder="manual"
className="mt-1"
{...register('reason')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Action</Label>
<Select value={selectedType} onValueChange={(v) => setValue('type', v as 'ban' | 'allow')}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ban">Ban (block)</SelectItem>
<SelectItem value="allow">Allow (whitelist)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Duration</Label>
<Select defaultValue="24h" onValueChange={(v) => setValue('duration', v)}>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">1 hour</SelectItem>
<SelectItem value="24h">24 hours</SelectItem>
<SelectItem value="168h">7 days</SelectItem>
<SelectItem value="720h">30 days</SelectItem>
<SelectItem value="8760h">1 year</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{blockSuccess && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{blockSuccess}</AlertDescription>
</Alert>
)}
{addDecisionError && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{(addDecisionError as Error).message}</AlertDescription>
</Alert>
)}
<Button type="submit" size="sm" disabled={isAddingDecision} className="gap-2">
{isAddingDecision ? <Loader2 className="h-4 w-4 animate-spin" /> : <Ban className="h-4 w-4" />}
{selectedType === 'allow' ? 'Add to Allowlist' : 'Block IP'}
</Button>
</form>
{/* Local decisions list */}
{decisions.length > 0 && (
<div className="border-t pt-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Active local decisions</p>
<div className="space-y-1 max-h-48 overflow-y-auto">
{decisions.map((d) => (
<div key={d.id} className="flex items-center justify-between py-1.5 px-2 bg-muted rounded text-xs">
<div className="flex items-center gap-2 min-w-0">
{d.type === 'allow'
? <CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
: <Ban className="h-3 w-3 text-red-500 shrink-0" />
}
<span className="font-mono truncate">{d.value}</span>
<Badge variant="outline" className="text-xs h-4 shrink-0">{d.type}</Badge>
<span className="text-muted-foreground truncate">{friendlyReason(d.reason)}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{d.duration && (
<span className="text-muted-foreground">{parseDuration(d.duration)}</span>
)}
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-muted-foreground hover:text-red-500"
onClick={() => deleteDecision(d.id)}
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Registered Agents */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<CardTitle>Registered Agents</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
<strong>Agents</strong> are CrowdSec engines running inside your k8s clusters. They analyze
logs, detect threats, and report to this LAPI. Each provisioned Wild Cloud instance registers
one agent here.
</p>
{status.machines.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-3">No agents registered. Provision an instance below.</p>
) : (
<div className="space-y-2">
{status.machines.map((m) => (
<div key={m.machineId} className="flex items-center justify-between py-2 px-3 bg-muted rounded">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{m.machineId}</span>
<Badge variant={m.isValidated ? 'default' : 'secondary'} className="text-xs h-4">
{m.isValidated ? 'validated' : 'pending'}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{m.last_heartbeat && <span>heartbeat {formatRelativeTime(m.last_heartbeat)}</span>}
{m.version && <span>{m.version}</span>}
{m.ipAddress && <span className="font-mono">{m.ipAddress}</span>}
</div>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-red-500"
onClick={() => deleteMachine(m.machineId)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Registered Bouncers */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<CardTitle>Registered Bouncers</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
<strong>Bouncers</strong> enforce the blocklist. Wild Central's nftables bouncer blocks at
the network perimeter. Each k8s cluster also runs a Traefik bouncer as a second layer.
</p>
{status.bouncers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-3">No bouncers registered. Provision an instance to add them.</p>
) : (
<div className="space-y-2">
{status.bouncers.map((b) => {
const isFirewallBouncer = b.type === 'crowdsec-firewall-bouncer';
return (
<div key={b.name} className="flex items-center justify-between py-2 px-3 bg-muted rounded">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-mono text-sm truncate max-w-64">{b.name}</span>
<Badge variant={b.revoked ? 'secondary' : 'default'} className="text-xs h-4">
{b.revoked ? 'revoked' : 'active'}
</Badge>
{isFirewallBouncer && (
<Badge variant="outline" className="text-xs h-4 border-orange-400 text-orange-600 dark:text-orange-400">
perimeter
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{isFirewallBouncer ? 'Crowdsec Firewall Bouncer' : b.type?.replace(/-/g, ' ') || ''}
</p>
</div>
{!isFirewallBouncer && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-muted-foreground hover:text-red-500"
onClick={() => deleteBouncer(b.name)}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Provision Instances */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<CardTitle>Provision Instances</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
<strong>Provisioning</strong> connects a Wild Cloud instance's CrowdSec agent to this LAPI —
generating credentials, registering agent and bouncer, and writing the LAPI URL into the
instance's app config. After provisioning, redeploy the CrowdSec app on the instance.
Safe to re-run.
</p>
<div className="space-y-2">
{instances.length === 0 ? (
<p className="text-sm text-muted-foreground">No instances found.</p>
) : (
instances.map((instanceName) => {
const agentName = `wc-${instanceName}`;
const bouncerName = `wc-bouncer-${instanceName}`;
const agent = status.machines.find((m) => m.machineId === agentName);
const bouncer = status.bouncers.find((b) => b.name === bouncerName);
const isActive = provisioningInstance === instanceName;
const result = provisionResults[instanceName];
return (
<div key={instanceName} className="rounded border p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-mono text-sm font-medium">{instanceName}</span>
<Button
size="sm"
variant={agent ? 'outline' : 'default'}
onClick={() => handleProvision(instanceName)}
disabled={isProvisioning || isActive}
className="gap-2 h-7"
>
{isActive
? <><Loader2 className="h-3 w-3 animate-spin" />Provisioning</>
: agent
? <><Zap className="h-3 w-3" />Re-provision</>
: <><Zap className="h-3 w-3" />Provision</>
}
</Button>
</div>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Agent:</span>
{agent ? (
<Badge variant={agent.isValidated ? 'default' : 'secondary'} className="text-xs h-4">
{agent.isValidated ? 'validated' : 'pending'}
{agent.last_heartbeat && ` · ${formatRelativeTime(agent.last_heartbeat)}`}
</Badge>
) : (
<span className="text-muted-foreground italic">not provisioned</span>
)}
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Bouncer:</span>
{bouncer ? (
<Badge variant={bouncer.revoked ? 'secondary' : 'default'} className="text-xs h-4">
{bouncer.revoked ? 'revoked' : 'active'}
</Badge>
) : (
<span className="text-muted-foreground italic">not provisioned</span>
)}
</div>
</div>
{result && (
<Alert variant={result.ok ? 'default' : 'error'} className="py-2">
{result.ok ? <CheckCircle className="h-3 w-3" /> : <AlertCircle className="h-3 w-3" />}
<AlertDescription className="text-xs">{result.message}</AlertDescription>
</Alert>
)}
</div>
);
})
)}
</div>
</CardContent>
</Card>
</>
)}
</div>
);
}