import { useState, useEffect, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Card } from './ui/card'; import { Button } from './ui/button'; import { Input } from './ui'; import { Alert, AlertDescription } from './ui/alert'; import { Badge } from './ui/badge'; import { LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2, BookOpen, Globe, Router, RotateCw, Key, Shield, Eye, ArrowLeftRight, Lock, } from 'lucide-react'; import { useCloudflare } from '../hooks/useCloudflare'; import { useCentralStatus } from '../hooks/useCentralStatus'; import { useVpn } from '../hooks/useVpn'; import { secretsApi, dnsSettingsApi, nftablesConfigApi } from '../services/api'; import { usePageHelp } from '../hooks/usePageHelp'; const SERVICES = [ { key: 'dnsmasq', label: 'DNS / DHCP', icon: Globe }, { key: 'haproxy', label: 'HAProxy', icon: ArrowLeftRight }, { key: 'nftables', label: 'Firewall', icon: Shield }, { key: 'wireguard', label: 'VPN', icon: Lock }, { key: 'crowdsec', label: 'CrowdSec', icon: Eye }, ]; function formatUptime(totalSeconds: number): string { const days = Math.floor(totalSeconds / 86400); const hours = Math.floor((totalSeconds % 86400) / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; if (days > 0) return `${days}d ${hours}h ${minutes}m`; if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; if (minutes > 0) return `${minutes}m ${seconds}s`; return `${seconds}s`; } function useLiveUptime(serverSeconds: number | undefined): string { const [elapsed, setElapsed] = useState(0); const baseRef = useRef(serverSeconds ?? 0); useEffect(() => { if (serverSeconds === undefined) return; baseRef.current = serverSeconds; setElapsed(0); }, [serverSeconds]); useEffect(() => { if (serverSeconds === undefined) return; const id = setInterval(() => setElapsed(e => e + 1), 1000); return () => clearInterval(id); }, [serverSeconds]); return formatUptime(baseRef.current + elapsed); } export function DashboardComponent() { const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus(); const { verification, isLoading: cfLoading } = useCloudflare(); const { config: vpnConfig } = useVpn(); const { data: dnsSettings } = useQuery({ queryKey: ['dns', 'settings'], queryFn: () => dnsSettingsApi.get() }); const { data: nftConfig } = useQuery({ queryKey: ['nftables', 'config'], queryFn: () => nftablesConfigApi.get() }); const queryClient = useQueryClient(); const { data: globalSecrets } = useQuery({ queryKey: ['globalSecrets'], queryFn: () => secretsApi.get(), }); const cfTokenIsSet = !!(globalSecrets as Record | undefined) && !!(globalSecrets as { cloudflare?: { apiToken?: string } })?.cloudflare?.apiToken; const [editingToken, setEditingToken] = useState(false); const [tokenValue, setTokenValue] = useState(''); const updateSecretsMutation = useMutation({ mutationFn: (values: Record) => secretsApi.update(values), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['globalSecrets'] }); queryClient.invalidateQueries({ queryKey: ['cloudflare', 'verify'] }); setEditingToken(false); setTokenValue(''); }, }); const cfTokenMissing = !cfLoading && !verification?.tokenConfigured; const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid; const hasWarnings = cfTokenMissing || cfTokenInvalid; const daemons = (centralStatus?.daemons ?? {}) as Record; const uptime = useLiveUptime(centralStatus?.uptimeSeconds); const ports = [ { port: 443, proto: 'TCP', label: 'HTTPS' }, { port: 80, proto: 'TCP', label: 'HTTP' }, ...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []), ...((nftConfig?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[]) .map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })), ]; usePageHelp({ title: 'What is the Dashboard?', description: ( <>

The Dashboard shows the health of Wild Central's infrastructure — the services and integrations that must be working for your cloud to serve traffic properly.

Green indicators mean everything is healthy. Yellow or red indicators need attention. Resolve issues from top to bottom, as later services often depend on earlier ones.

), icon: , color: 'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20', }); return (
{/* Page Header with system metadata */}

Dashboard

System health overview

{!statusLoading && centralStatus ? (
{centralStatus.version} | up {uptime}
) : !statusLoading ? ( Offline ) : null}
{/* Warnings */} {hasWarnings && (
{cfTokenMissing && ( Cloudflare API token is not configured. DNS and TLS features are unavailable. )} {cfTokenInvalid && ( Cloudflare API token is invalid. Check your token in Cloudflare settings. )}
)} {/* Services */}
Services
{SERVICES.map((svc) => ( ))}
{/* Configuration */}
Configuration
{/* Cloudflare */}
Cloudflare
{cfLoading ? ( ) : verification?.tokenValid ? ( Connected ) : verification?.tokenConfigured ? ( Invalid ) : ( Not configured )}
{cfLoading ? (
Verifying...
) : verification?.tokenValid ? (
{!editingToken ? ( ) : (
setTokenValue(e.target.value)} />
)}
) : (
{!editingToken ? (

{verification?.tokenConfigured ? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}` : 'Required for DNS and TLS'}

) : (
setTokenValue(e.target.value)} />
{updateSecretsMutation.isError && (

{String(updateSecretsMutation.error)}

)}
)}
)}
{/* Router */}
Router
LAN setup checklist

Configure your LAN router so Wild Central can serve traffic:

  1. Set the router's DNS server to Wild Central {dnsSettings?.ip && ( {dnsSettings.ip} )}
  2. Forward these ports to Wild Central:
    {ports.map((p, i) => ( {p.port}/{p.proto} {p.label && ({p.label})} ))}
); } function ZoneCoverage({ available, required }: { available: { id: string; name: string }[] | null; required: string[] | null; }) { const availableNames = new Set((available ?? []).map(z => z.name)); const requiredNames = required ?? []; // Merge: all required zones (checked/unchecked) + any extra available zones not in required const allZones: { name: string; covered: boolean; needed: boolean }[] = []; for (const name of requiredNames) { allZones.push({ name, covered: availableNames.has(name), needed: true }); } for (const z of available ?? []) { if (!requiredNames.includes(z.name)) { allZones.push({ name: z.name, covered: true, needed: false }); } } if (allZones.length === 0) return null; return (
Zones
{allZones.map((z) => (
{z.needed ? ( z.covered ? ( ) : ( ) ) : ( )} {z.name} {!z.covered && z.needed && ( not covered )} {!z.needed && ( unused )}
))}
); } function PermissionRow({ label, description, ok }: { label: string; description: string; ok?: boolean }) { return (
{ok ? ( ) : ( )} {label} {description}
); } function ServiceCard({ label, daemon, icon: Icon, active, version, loading }: { label: string; daemon: string; icon: typeof Globe; active?: boolean; version?: string; loading: boolean; }) { return (
{loading ? ( ) : ( )}
{label}
{daemon}
{version &&
{version}
}
); }