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:
432
web/src/components/DashboardComponent.tsx
Normal file
432
web/src/components/DashboardComponent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user