443 lines
18 KiB
TypeScript
443 lines
18 KiB
TypeScript
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<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('');
|
|
},
|
|
});
|
|
|
|
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
|
|
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid;
|
|
const hasWarnings = cfTokenMissing || cfTokenInvalid;
|
|
|
|
const daemons = (centralStatus?.daemons ?? {}) as Record<string, { active?: boolean; version?: string }>;
|
|
|
|
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: (
|
|
<>
|
|
<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 with system metadata */}
|
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
|
<div className="flex items-center gap-4">
|
|
<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">System health overview</p>
|
|
</div>
|
|
</div>
|
|
{!statusLoading && centralStatus ? (
|
|
<div className="flex items-center gap-3 text-sm text-muted-foreground sm:mt-1">
|
|
<span className="font-mono text-xs">{centralStatus.version}</span>
|
|
<span className="text-border">|</span>
|
|
<span>up <span className="font-mono">{uptime}</span></span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={restartDaemon}
|
|
disabled={isRestarting}
|
|
className="gap-1 h-7 text-xs"
|
|
>
|
|
{isRestarting ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCw className="h-3 w-3" />}
|
|
{isRestarting ? 'Restarting...' : 'Restart'}
|
|
</Button>
|
|
</div>
|
|
) : !statusLoading ? (
|
|
<Badge variant="destructive">Offline</Badge>
|
|
) : null}
|
|
</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>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Services */}
|
|
<div>
|
|
<div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-3">Services</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
|
{SERVICES.map((svc) => (
|
|
<ServiceCard
|
|
key={svc.key}
|
|
label={svc.label}
|
|
daemon={svc.key}
|
|
icon={svc.icon}
|
|
active={daemons[svc.key]?.active}
|
|
version={daemons[svc.key]?.version}
|
|
loading={statusLoading}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Configuration */}
|
|
<div>
|
|
<div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-3">Configuration</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{/* Cloudflare */}
|
|
<Card className="p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2.5 bg-orange-500/10 rounded-xl">
|
|
<Cloud className="h-5 w-5 text-orange-500" />
|
|
</div>
|
|
<div className="font-semibold">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">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Verifying...
|
|
</div>
|
|
) : verification?.tokenValid ? (
|
|
<div className="space-y-3">
|
|
<div className="space-y-1">
|
|
<PermissionRow label="Zone:Read" description="List DNS zones" ok={verification.permissions?.zone} />
|
|
<PermissionRow label="DNS:Edit" description="Manage DNS records" ok={verification.permissions?.dns} />
|
|
</div>
|
|
<ZoneCoverage
|
|
available={verification.zones}
|
|
required={verification.requiredZones}
|
|
/>
|
|
{!editingToken ? (
|
|
<Button variant="ghost" size="sm" onClick={() => setEditingToken(true)} className="gap-1 h-7 text-xs text-muted-foreground">
|
|
<Key className="h-3 w-3" />
|
|
Change Token
|
|
</Button>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<Input
|
|
type="password"
|
|
placeholder="New 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={!tokenValue}>
|
|
Save
|
|
</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => { setEditingToken(false); setTokenValue(''); }}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{!editingToken ? (
|
|
<div className="space-y-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
{verification?.tokenConfigured
|
|
? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}`
|
|
: 'Required for DNS and TLS'}
|
|
</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>
|
|
|
|
{/* Router */}
|
|
<Card className="p-5">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2.5 bg-amber-500/10 rounded-xl">
|
|
<Router className="h-5 w-5 text-amber-500" />
|
|
</div>
|
|
<div>
|
|
<div className="font-semibold">Router</div>
|
|
<div className="text-xs text-muted-foreground">LAN setup checklist</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-sm text-muted-foreground space-y-2">
|
|
<p>Configure your LAN router so Wild Central can serve traffic:</p>
|
|
<ol className="list-decimal list-inside space-y-1.5 text-xs">
|
|
<li>
|
|
Set the router's DNS server to Wild Central
|
|
{dnsSettings?.ip && (
|
|
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">{dnsSettings.ip}</span>
|
|
)}
|
|
</li>
|
|
<li>
|
|
Forward these ports to Wild Central:
|
|
<div className="flex flex-wrap gap-1 mt-1 ml-4">
|
|
{ports.map((p, i) => (
|
|
<span key={i} className="font-mono bg-muted px-2 py-0.5 rounded">
|
|
{p.port}/{p.proto}
|
|
{p.label && <span className="text-muted-foreground/60"> ({p.label})</span>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div>
|
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Zones</div>
|
|
<div className="space-y-1">
|
|
{allZones.map((z) => (
|
|
<div key={z.name} className="flex items-center gap-2 text-xs">
|
|
{z.needed ? (
|
|
z.covered ? (
|
|
<CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
|
|
) : (
|
|
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
|
|
)
|
|
) : (
|
|
<CheckCircle className="h-3 w-3 text-muted-foreground/40 shrink-0" />
|
|
)}
|
|
<span className="font-mono">{z.name}</span>
|
|
{!z.covered && z.needed && (
|
|
<span className="text-red-500">not covered</span>
|
|
)}
|
|
{!z.needed && (
|
|
<span className="text-muted-foreground">unused</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PermissionRow({ label, description, ok }: { label: string; description: string; ok?: boolean }) {
|
|
return (
|
|
<div className="flex items-center gap-2 text-xs">
|
|
{ok ? (
|
|
<CheckCircle className="h-3 w-3 text-green-500 shrink-0" />
|
|
) : (
|
|
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
|
|
)}
|
|
<span className="font-mono font-medium">{label}</span>
|
|
<span className="text-muted-foreground">{description}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ServiceCard({ label, daemon, icon: Icon, active, version, loading }: {
|
|
label: string;
|
|
daemon: string;
|
|
icon: typeof Globe;
|
|
active?: boolean;
|
|
version?: string;
|
|
loading: boolean;
|
|
}) {
|
|
return (
|
|
<Card className={`p-4 transition-colors ${active === false ? 'border-red-500/40' : ''}`}>
|
|
<div className="flex flex-col items-center gap-2 text-center">
|
|
<div className={`p-2.5 rounded-xl ${
|
|
loading ? 'bg-muted' :
|
|
active ? 'bg-green-500/10' :
|
|
active === false ? 'bg-red-500/10' :
|
|
'bg-muted'
|
|
}`}>
|
|
{loading ? (
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<Icon className={`h-5 w-5 ${
|
|
active ? 'text-green-600 dark:text-green-400' :
|
|
active === false ? 'text-red-500' :
|
|
'text-muted-foreground'
|
|
}`} />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium">{label}</div>
|
|
<div className="text-[10px] text-muted-foreground font-mono">{daemon}</div>
|
|
{version && <div className="text-[10px] text-muted-foreground font-mono">{version}</div>}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|