feat: UI refactor — Dashboard + Services, 9 items → 6

Major restructure of Wild Central's web UI:

New pages:
- Dashboard: infrastructure health (Cloudflare token, DDNS sync,
  daemon status, gateway router guidance, cert warnings)
- Services: every registered service with expandable rows showing
  DNS, proxy, TLS, DDNS status. Custom TCP routes section.
  Advanced HAProxy management collapsed at bottom.

Sidebar restructured into two groups:
- Services: Dashboard, Services
- Central: Firewall, VPN, CrowdSec, DHCP

Deleted 7 pages + 7 components (2501 lines removed):
- CentralPage, CloudflarePage, DdnsPage, DnsPage, LanDnsPage,
  CertificatesPage, IngressProxyPage
- CentralComponent, CloudflareComponent, DdnsComponent,
  DnsComponent, LanDnsComponent, IngressProxyComponent,
  DnsmasqSection

All hooks and API services kept unchanged — reused in new components.
Type-check and production build pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 22:36:54 +00:00
parent c18217944f
commit 9ce9999d5c
21 changed files with 1017 additions and 2501 deletions

View File

@@ -0,0 +1,432 @@
import { useState } 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,
RefreshCw, BookOpen, Globe, Shield, Router, Server, RotateCw, Key,
} from 'lucide-react';
import { useCloudflare } from '../hooks/useCloudflare';
import { useDdns } from '../hooks/useDdns';
import { useCentralStatus } from '../hooks/useCentralStatus';
import { useCert } from '../hooks/useCert';
import { useConfig } from '../hooks';
import { secretsApi } from '../services/api';
import { usePageHelp } from '../hooks/usePageHelp';
import type { DdnsRecordStatus } from '../services/api/networking';
export function DashboardComponent() {
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
const { verification, isLoading: cfLoading } = useCloudflare();
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
const { status: certStatus } = useCert();
const { config: globalConfig } = useConfig();
const queryClient = useQueryClient();
const { data: globalSecrets } = useQuery({
queryKey: ['globalSecrets'],
queryFn: () => secretsApi.get(),
});
const cfTokenIsSet = !!(globalSecrets as Record<string, unknown> | undefined)
&& !!(globalSecrets as { cloudflare?: { apiToken?: string } })?.cloudflare?.apiToken;
const [editingToken, setEditingToken] = useState(false);
const [tokenValue, setTokenValue] = useState('');
const updateSecretsMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => secretsApi.update(values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['globalSecrets'] });
queryClient.invalidateQueries({ queryKey: ['cloudflare', 'verify'] });
setEditingToken(false);
setTokenValue('');
},
});
// Warning conditions
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid;
const certExpiring = certStatus?.certs?.some(c => c.cert.exists && (c.cert.daysLeft ?? 999) < 14);
const certMissing = certStatus?.certs?.some(c => !c.cert.exists);
const ddnsFailed = ddnsStatus?.enabled && ddnsStatus?.lastError;
const hasWarnings = cfTokenMissing || cfTokenInvalid || certExpiring || certMissing || ddnsFailed;
usePageHelp({
title: 'What is the Dashboard?',
description: (
<>
<p className="mb-3 leading-relaxed">
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.
</p>
<p className="text-sm">
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.
</p>
</>
),
icon: <BookOpen className="h-6 w-6 text-blue-600 dark:text-blue-400" />,
color: 'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20',
});
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<LayoutDashboard className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Dashboard</h2>
<p className="text-muted-foreground">Infrastructure health and service prerequisites</p>
</div>
</div>
{/* Warnings */}
{hasWarnings && (
<div className="space-y-2">
{cfTokenMissing && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Cloudflare API token is not configured. DNS and TLS features are unavailable.</AlertDescription>
</Alert>
)}
{cfTokenInvalid && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Cloudflare API token is invalid. Check your token in Cloudflare settings.</AlertDescription>
</Alert>
)}
{certMissing && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>One or more TLS certificates are missing. Services may not be reachable over HTTPS.</AlertDescription>
</Alert>
)}
{certExpiring && !certMissing && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>A TLS certificate expires within 14 days. Consider renewing soon.</AlertDescription>
</Alert>
)}
{ddnsFailed && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription>DDNS sync error: {ddnsStatus.lastError}</AlertDescription>
</Alert>
)}
</div>
)}
{/* 1. Cloudflare */}
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Cloud className="h-5 w-5 text-blue-500" />
<div className="font-medium">Cloudflare</div>
</div>
{cfLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : verification?.tokenValid ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Connected
</Badge>
) : verification?.tokenConfigured ? (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Invalid
</Badge>
) : (
<Badge variant="secondary">Not configured</Badge>
)}
</div>
{cfLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
<Loader2 className="h-4 w-4 animate-spin" />
Checking token...
</div>
) : verification?.tokenValid ? (
<div className="space-y-2 ml-7">
{verification.zones && verification.zones.length > 0 && (
<div className="flex items-start gap-2 text-sm">
<Globe className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<span className="text-muted-foreground">Zones: </span>
<span className="inline-flex flex-wrap gap-1">
{verification.zones.map((zone) => (
<span key={zone.id} className="font-mono bg-muted px-2 py-0.5 rounded text-xs">
{zone.name}
</span>
))}
</span>
</div>
</div>
)}
</div>
) : (
<div className="ml-7">
{!editingToken ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{verification?.tokenConfigured
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
: 'An API token is required for DNS and TLS features'}
</p>
<Button variant="outline" size="sm" onClick={() => setEditingToken(true)} className="gap-1">
<Key className="h-3 w-3" />
{cfTokenIsSet ? 'Update Token' : 'Set Token'}
</Button>
</div>
) : (
<div className="space-y-2">
<Input
type="password"
placeholder="Cloudflare API token..."
value={tokenValue}
onChange={(e) => setTokenValue(e.target.value)}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => updateSecretsMutation.mutate({ cloudflare: { apiToken: tokenValue } })}
disabled={updateSecretsMutation.isPending || !tokenValue}
>
{updateSecretsMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Save
</Button>
<Button variant="outline" size="sm" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
Cancel
</Button>
</div>
{updateSecretsMutation.isError && (
<p className="text-xs text-red-500">{String(updateSecretsMutation.error)}</p>
)}
</div>
)}
</div>
)}
</Card>
{/* 2. Dynamic DNS */}
<Card className="p-4 border-l-4 border-l-green-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-green-500" />
<div className="font-medium">Dynamic DNS</div>
</div>
<div className="flex items-center gap-2">
{ddnsLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : ddnsStatus?.enabled ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Active
</Badge>
) : (
<Badge variant="secondary">Disabled</Badge>
)}
</div>
</div>
{ddnsStatus?.enabled ? (
<div className="space-y-3 ml-7">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Public IP</span>
<div className="font-mono font-medium mt-0.5">{ddnsStatus.currentIP || '--'}</div>
</div>
<div>
<span className="text-muted-foreground">Last checked</span>
<div className="font-mono mt-0.5">
{ddnsStatus.lastChecked ? new Date(ddnsStatus.lastChecked).toLocaleTimeString() : '--'}
</div>
</div>
</div>
{/* Record list */}
{globalConfig?.cloud?.ddns?.records?.length ? (
<div className="space-y-1">
{globalConfig.cloud.ddns.records.map((r) => {
const rs: DdnsRecordStatus | undefined = ddnsStatus.records?.find(s => s.name === r);
return (
<div key={r} className="flex items-center gap-2 text-xs">
{rs?.ok ? (
<CheckCircle className="h-3.5 w-3.5 text-green-500 shrink-0" />
) : rs && !rs.ok ? (
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
) : (
<div className="h-3.5 w-3.5 shrink-0" />
)}
<span className="font-mono bg-muted px-2 py-0.5 rounded">{r}</span>
{rs?.ok && rs.ip && (
<span className="text-muted-foreground font-mono">{rs.ip}</span>
)}
{rs && !rs.ok && rs.error && (
<span className="text-red-500 truncate" title={rs.error}>{rs.error}</span>
)}
</div>
);
})}
</div>
) : null}
<Button
variant="outline"
size="sm"
onClick={() => triggerDdns()}
disabled={isTriggering}
className="gap-1"
>
{isTriggering ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
Check Now
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground ml-7">
DDNS is not enabled. Configure it in the DDNS settings to keep DNS records in sync with your public IP.
</p>
)}
</Card>
{/* 3. Daemon Status */}
<Card className="p-4 border-l-4 border-l-purple-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-purple-500" />
<div className="font-medium">Daemon Status</div>
</div>
{statusLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : centralStatus ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Running
</Badge>
) : (
<Badge variant="destructive">Offline</Badge>
)}
</div>
{statusLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
<Loader2 className="h-4 w-4 animate-spin" />
Loading status...
</div>
) : centralStatus ? (
<div className="space-y-3 ml-7">
{/* Service badges */}
<div className="flex flex-wrap gap-2">
<ServiceBadge name="dnsmasq" />
<ServiceBadge name="haproxy" />
<ServiceBadge name="wireguard" />
<ServiceBadge name="crowdsec" />
</div>
{/* Version and uptime */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Version</span>
<div className="font-mono font-medium mt-0.5">{centralStatus.version || 'Unknown'}</div>
</div>
<div>
<span className="text-muted-foreground">Uptime</span>
<div className="font-mono mt-0.5">{centralStatus.uptime || '--'}</div>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={restartDaemon}
disabled={isRestarting}
className="gap-1"
>
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
{isRestarting ? 'Restarting...' : 'Restart Daemon'}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground ml-7">Cannot reach the Wild Central daemon.</p>
)}
</Card>
{/* 4. TLS Certificates */}
{certStatus?.certs && certStatus.certs.length > 0 && (
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
<div className="font-medium">TLS Certificates</div>
</div>
{(() => {
const allExist = certStatus.certs!.every(c => c.cert.exists);
const someExist = certStatus.certs!.some(c => c.cert.exists);
if (allExist) {
return <Badge variant="success" className="gap-1"><CheckCircle className="h-3 w-3" />All valid</Badge>;
} else if (someExist) {
return <Badge variant="warning" className="gap-1"><AlertCircle className="h-3 w-3" />Partial</Badge>;
} else {
return <Badge variant="destructive" className="gap-1"><XCircle className="h-3 w-3" />Missing</Badge>;
}
})()}
</div>
<div className="space-y-2 ml-7">
{certStatus.certs!.map((entry) => (
<div key={entry.domain} className="flex items-center justify-between text-sm">
<span className="font-mono">{entry.domain}</span>
{entry.cert.exists ? (
<span className="text-xs text-green-600 dark:text-green-400">
Valid ({entry.cert.daysLeft}d)
</span>
) : (
<span className="text-xs text-red-500">Missing</span>
)}
</div>
))}
</div>
</Card>
)}
{/* 5. Gateway Router */}
<Card className="p-4 border-l-4 border-l-amber-500 bg-gradient-to-r from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10">
<div className="flex items-center gap-2 mb-3">
<Router className="h-5 w-5 text-amber-500" />
<div className="font-medium">Gateway Router</div>
</div>
<div className="space-y-2 ml-7 text-sm text-muted-foreground">
<p>Ensure your router is configured for Wild Central to work correctly:</p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li>
Forward DNS to Wild Central
{globalConfig?.cloud?.dnsmasq?.ip && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">
{globalConfig.cloud.dnsmasq.ip}
</span>
)}
</li>
<li>Forward ports <span className="font-mono">443</span>, <span className="font-mono">80</span>, <span className="font-mono">51820</span> to Wild Central</li>
<li>Set Wild Central as the primary DNS server for your LAN</li>
</ul>
</div>
</Card>
</div>
);
}
/** Inline badge showing a service name with a colored dot. Status is informational only. */
function ServiceBadge({ name }: { name: string }) {
// These are presentational — the daemon is running if we can reach the API,
// and individual service health comes from their own endpoints.
return (
<span className="inline-flex items-center gap-1.5 rounded-md bg-muted px-2 py-1 text-xs font-medium">
<span className="h-2 w-2 rounded-full bg-green-500" />
{name}
</span>
);
}