From 9ce9999d5c46153c0632c66a8cb167da6613a98f Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 9 Jul 2026 22:36:54 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20UI=20refactor=20=E2=80=94=20Dashboard?= =?UTF-8?q?=20+=20Services,=209=20items=20=E2=86=92=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- web/src/components/AppSidebar.tsx | 86 ++-- web/src/components/CentralComponent.tsx | 219 -------- web/src/components/CloudflareComponent.tsx | 507 ------------------- web/src/components/DashboardComponent.tsx | 432 ++++++++++++++++ web/src/components/DdnsComponent.tsx | 280 ---------- web/src/components/DnsComponent.tsx | 484 ------------------ web/src/components/DnsmasqSection.tsx | 86 ---- web/src/components/IngressProxyComponent.tsx | 348 ------------- web/src/components/LanDnsComponent.tsx | 259 ---------- web/src/components/ServicesComponent.tsx | 503 ++++++++++++++++++ web/src/components/index.ts | 4 - web/src/router/pages/CentralPage.tsx | 10 - web/src/router/pages/CertificatesPage.tsx | 204 -------- web/src/router/pages/CloudflarePage.tsx | 10 - web/src/router/pages/DashboardPage.tsx | 10 + web/src/router/pages/DdnsPage.tsx | 10 - web/src/router/pages/DnsPage.tsx | 10 - web/src/router/pages/IngressProxyPage.tsx | 10 - web/src/router/pages/LanDnsPage.tsx | 10 - web/src/router/pages/ServicesPage.tsx | 10 + web/src/router/routes.tsx | 26 +- 21 files changed, 1017 insertions(+), 2501 deletions(-) delete mode 100644 web/src/components/CentralComponent.tsx delete mode 100644 web/src/components/CloudflareComponent.tsx create mode 100644 web/src/components/DashboardComponent.tsx delete mode 100644 web/src/components/DdnsComponent.tsx delete mode 100644 web/src/components/DnsComponent.tsx delete mode 100644 web/src/components/DnsmasqSection.tsx delete mode 100644 web/src/components/IngressProxyComponent.tsx delete mode 100644 web/src/components/LanDnsComponent.tsx create mode 100644 web/src/components/ServicesComponent.tsx delete mode 100644 web/src/router/pages/CentralPage.tsx delete mode 100644 web/src/router/pages/CertificatesPage.tsx delete mode 100644 web/src/router/pages/CloudflarePage.tsx create mode 100644 web/src/router/pages/DashboardPage.tsx delete mode 100644 web/src/router/pages/DdnsPage.tsx delete mode 100644 web/src/router/pages/DnsPage.tsx delete mode 100644 web/src/router/pages/IngressProxyPage.tsx delete mode 100644 web/src/router/pages/LanDnsPage.tsx create mode 100644 web/src/router/pages/ServicesPage.tsx diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index f09e863..3661166 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router'; -import { Sun, Moon, Monitor, Shield, ShieldCheck, Lock, Network, ShieldAlert, Router, Cloud, Wifi, Server } from 'lucide-react'; +import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, Wifi, LayoutDashboard, Globe, CloudLightning } from 'lucide-react'; import { Sidebar, SidebarContent, @@ -52,14 +52,14 @@ export function AppSidebar() { } }; + const servicesItems = [ + { to: '/central', icon: LayoutDashboard, label: 'Dashboard', end: true }, + { to: '/central/services', icon: Globe, label: 'Services' }, + ]; + const centralItems = [ - { to: '/central', icon: Server, label: 'Overview', end: true }, - { to: '/central/cloudflare', icon: Cloud, label: 'Cloudflare' }, - { to: '/central/dns', icon: Router, label: 'DNS' }, - { to: '/central/ingress', icon: Network, label: 'Ingress Proxy' }, { to: '/central/firewall', icon: Shield, label: 'Firewall' }, { to: '/central/vpn', icon: Lock, label: 'VPN' }, - { to: '/central/certificates', icon: ShieldCheck, label: 'Certificates' }, { to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec' }, { to: '/central/dhcp', icon: Wifi, label: 'DHCP' }, ]; @@ -69,7 +69,7 @@ export function AppSidebar() {
- +

Wild Central

@@ -80,13 +80,13 @@ export function AppSidebar() { {state === 'collapsed' ? ( - {centralItems.map(({ to, icon: Icon, label, end }) => ( - - + {[...servicesItems, ...centralItems].map((item) => ( + + {({ isActive }) => ( - - - {label} + + + {item.label} )} @@ -94,25 +94,47 @@ export function AppSidebar() { ))} ) : ( - - Central - - - {centralItems.map(({ to, icon: Icon, label, end }) => ( - - - {({ isActive }) => ( - - - {label} - - )} - - - ))} - - - + <> + + Services + + + {servicesItems.map(({ to, icon: Icon, label, end }) => ( + + + {({ isActive }) => ( + + + {label} + + )} + + + ))} + + + + + + Central + + + {centralItems.map(({ to, icon: Icon, label }) => ( + + + {({ isActive }) => ( + + + {label} + + )} + + + ))} + + + + )} diff --git a/web/src/components/CentralComponent.tsx b/web/src/components/CentralComponent.tsx deleted file mode 100644 index 360ed28..0000000 --- a/web/src/components/CentralComponent.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Card } from './ui/card'; -import { Button } from './ui/button'; -import { Input, Label } from './ui'; -import { Alert, AlertDescription } from './ui/alert'; -import { HardDrive, Settings, CheckCircle, BookOpen, ExternalLink, Loader2, AlertCircle, Mail, Edit2, Check, X, RotateCw } from 'lucide-react'; -import { Badge } from './ui/badge'; -import { useCentralStatus } from '../hooks/useCentralStatus'; -import { useConfig } from '../hooks'; -import { usePageHelp } from '../hooks/usePageHelp'; - -export function CentralComponent() { - const { data: centralStatus, isLoading: statusLoading, error: statusError, restart: restartDaemon, isRestarting: isDaemonRestarting } = useCentralStatus(); - const { config: globalConfig, updateConfig: updateGlobalConfig, isUpdating } = useConfig(); - - const [editingOperator, setEditingOperator] = useState(false); - const [operatorEmail, setOperatorEmail] = useState(''); - const [successMessage, setSuccessMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - - const showSuccess = (msg: string) => { - setSuccessMessage(msg); - setErrorMessage(null); - setTimeout(() => setSuccessMessage(null), 5000); - }; - - const showError = (msg: string) => { - setErrorMessage(msg); - setSuccessMessage(null); - setTimeout(() => setErrorMessage(null), 8000); - }; - - useEffect(() => { - if (globalConfig?.operator?.email) { - setOperatorEmail(globalConfig.operator.email); - } - }, [globalConfig]); - - const handleOperatorSave = async () => { - if (!globalConfig || !operatorEmail) return; - try { - await updateGlobalConfig({ - ...globalConfig, - operator: { ...globalConfig.operator, email: operatorEmail }, - }); - setEditingOperator(false); - showSuccess('Operator email saved'); - } catch (err) { - showError(`Failed to save operator: ${err instanceof Error ? err.message : String(err)}`); - } - }; - - const handleOperatorCancel = () => { - setOperatorEmail(globalConfig?.operator?.email || ''); - setEditingOperator(false); - }; - - usePageHelp({ - title: 'What is the Central Service?', - description: ( - <> -

- The Central Service is the "brain" of your personal cloud. It acts as the main coordinator that manages - all the different services running on your network. Think of it like the control tower at an airport - - it keeps track of what's happening, routes traffic between services, and ensures everything works together smoothly. -

-

- This service handles configuration management, service discovery, and provides the web interface you're using right now. -

- - ), - icon: , - color: 'bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20', - actions: ( - - ), - }); - - if (statusError) { - return ( - - -

Error Loading Central Status

-

- {(statusError as Error)?.message || 'An error occurred'} -

- -
- ); - } - - return ( -
-
-
-

Wild Central

-

Manage your Wild Central server

-
- {centralStatus && ( - - - {centralStatus.status === 'running' ? 'Running' : centralStatus.status} - - )} -
- - {centralStatus?.pendingRestart && ( -
-
- - Configuration changed — restart Wild Central for changes to take effect. -
- -
- )} - - {successMessage && ( - - - {successMessage} - - )} - {errorMessage && ( - - - {errorMessage} - - )} - - {statusLoading ? ( - -
- -
-
- ) : ( - -
- -
-
Version
-
{centralStatus?.version || 'Unknown'}
-
-
-
- -
-
Data Directory
-
- {centralStatus?.dataDir || '/var/lib/wild-central'} -
-
-
-
- )} - - -
-
- -
Operator Email
-
- {!editingOperator && ( - - )} -
- {editingOperator ? ( -
-
- - setOperatorEmail(e.target.value)} - placeholder="email@example.com" - className="mt-1" - /> -
-
- - -
-
- ) : ( -
- {globalConfig?.operator?.email || ( - Not configured - )} -
- )} -
- -
- ); -} diff --git a/web/src/components/CloudflareComponent.tsx b/web/src/components/CloudflareComponent.tsx deleted file mode 100644 index 7a08904..0000000 --- a/web/src/components/CloudflareComponent.tsx +++ /dev/null @@ -1,507 +0,0 @@ -import { useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Card, CardContent } from './ui/card'; -import { Button } from './ui/button'; -import { Input, Label } from './ui'; -import { Alert, AlertDescription } from './ui/alert'; -import { - Cloud, CheckCircle, XCircle, AlertCircle, Loader2, Edit2, - RefreshCw, ExternalLink, BookOpen, Globe, Key, Shield, Check, X, KeyRound, -} from 'lucide-react'; -import { Badge } from './ui/badge'; -import { useCloudflare } from '../hooks/useCloudflare'; -import { useConfig } from '../hooks'; -import { useDdns } from '../hooks/useDdns'; -import { useCert } from '../hooks/useCert'; -import { secretsApi } from '../services/api'; -import { usePageHelp } from '../hooks/usePageHelp'; - -export function CloudflareComponent() { - const { verification, isLoading: isVerifying, refetch: reverify } = useCloudflare(); - const { config: globalConfig, updateConfig: updateGlobalConfig, isUpdating } = useConfig(); - const { status: ddnsStatus } = useDdns(); - const { status: certStatus, isLoading: certLoading, provision: provisionCert, isProvisioning, provisionError, renew: renewCerts, isRenewing } = useCert(); - - const queryClient = useQueryClient(); - const { data: globalSecrets } = useQuery({ - queryKey: ['globalSecrets'], - queryFn: () => secretsApi.get(), - }); - const updateSecretsMutation = useMutation({ - mutationFn: (values: Record) => secretsApi.update(values), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['globalSecrets'] }); - queryClient.invalidateQueries({ queryKey: ['cloudflare', 'verify'] }); - setEditingToken(false); - setTokenValue(''); - showSuccess('Cloudflare API token saved'); - }, - onError: (err) => { - showError(String(err)); - }, - }); - - const cfTokenIsSet = !!(globalSecrets as Record | undefined) - && !!(globalSecrets as { cloudflare?: { apiToken?: string } })?.cloudflare?.apiToken; - - const [editingToken, setEditingToken] = useState(false); - const [tokenValue, setTokenValue] = useState(''); - const [editingDomain, setEditingDomain] = useState(false); - const [domainInput, setDomainInput] = useState(''); - const [successMessage, setSuccessMessage] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - - const showSuccess = (msg: string) => { - setSuccessMessage(msg); - setErrorMessage(null); - setTimeout(() => setSuccessMessage(null), 5000); - }; - - const showError = (msg: string) => { - setErrorMessage(msg); - setSuccessMessage(null); - setTimeout(() => setErrorMessage(null), 8000); - }; - - usePageHelp({ - title: 'Why Cloudflare?', - description: ( - <> -

- Wild Cloud uses Cloudflare to manage DNS records and provision TLS certificates for your domains. - A Cloudflare API token enables three key features: Dynamic DNS keeps your domain - pointed at your changing public IP, TLS certificates are provisioned via - Let's Encrypt DNS-01 challenges, and ExternalDNS automatically creates DNS records - when you deploy apps. -

-

- Create an API token in your Cloudflare dashboard with Zone:DNS:Edit and{' '} - Zone:Zone:Read permissions, scoped to your domain's zone. -

- - ), - icon: , - color: 'bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-950/20 dark:to-amber-950/20', - actions: ( - - - - ), - }); - - return ( -
- {/* Page Header */} -
-
-

Cloudflare

-

Manage your Cloudflare integration for DNS and TLS

-
- {verification?.tokenValid && ( - - - Connected - - )} -
- - {/* Alerts */} - {successMessage && ( - - - {successMessage} - - )} - {errorMessage && ( - - - {errorMessage} - - )} - - {/* API Token Card */} - -
-
- -
API Token
-
- {!editingToken && ( - - )} -
- {!editingToken && ( - cfTokenIsSet - ?

Configured

- :

Not set — required for DNS and TLS features

- )} - {editingToken && ( -
- setTokenValue(e.target.value)} - /> -
- - -
-
- )} -
- - {/* Central Domain & TLS Card */} - -
-
- -
Central Domain
-
-
- {certStatus?.certs?.some(c => c.cert.exists) && ( - - - TLS - - )} - {!editingDomain && ( - - )} -
-
- - {editingDomain ? ( -
-
- - setDomainInput(e.target.value)} - placeholder="central.example.com" - className="mt-1 font-mono" - /> -

- Must resolve to this server (via internal DNS or VPN). -

-
-
- - -
-
- ) : ( -
-
- {globalConfig?.cloud?.central?.domain ? ( - - {globalConfig.cloud.central.domain} - - ) : ( - Not configured - )} -
- - {globalConfig?.cloud?.central?.domain && (() => { - const centralCert = certStatus?.certs?.find(c => c.domain === globalConfig?.cloud?.central?.domain); - return ( -
- {certLoading ? ( - - ) : centralCert?.cert.exists ? ( -
-
- - Valid TLS certificate -
-
- Expires in {centralCert.cert.daysLeft} days - {centralCert.cert.issuerCN && <> · {centralCert.cert.issuerCN}} -
-
- ) : ( -
-
- - No TLS certificate -
- - {provisionError && ( - - - {(provisionError as Error).message} - - )} -
- )} -
- ); - })()} -
- )} -
- - {/* TLS Certificates Card */} - {certStatus?.certs && certStatus.certs.length > 0 && ( - -
-
- -
TLS Certificates
-
-
-
- {certStatus.certs.map((entry) => ( -
-
- {entry.domain} - {entry.source} -
-
- {entry.cert.exists ? ( - - - Valid ({entry.cert.daysLeft}d) - - ) : ( - <> - - - Missing - - {certStatus.canProvision && ( - - )} - - )} -
-
- ))} - {provisionError && ( - - - {(provisionError as Error).message} - - )} -
- -
-
-
- )} - - {/* Verification Checklist Card */} - -
-
- -
Token Verification
-
- -
- - {isVerifying && !verification ? ( -
- - Verifying token... -
- ) : ( -
- {/* Token configured */} -
- {verification?.tokenConfigured - ? - : } - Token configured -
- - {/* Token valid */} - {verification?.tokenConfigured && ( -
- {verification?.tokenValid - ? - : } - Token valid{verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''} -
- )} - - {/* Accessible zones */} - {verification?.tokenValid && verification.zones && verification.zones.length > 0 && ( -
- -
- Accessible zones: - - {verification.zones.map((zone) => ( - - {zone.name} - - ))} - -
-
- )} - - {/* Error */} - {verification?.error && ( -
- - {verification.error} -
- )} -
- )} -
- - {/* Feature Status Card */} - -
-
- -
Features Using This Token
-
-
- -
- {/* DDNS */} -
-
- - Dynamic DNS -
- {ddnsStatus?.enabled - ? Active - : Disabled} -
- - {/* TLS Certificates */} -
-
- - TLS Certificates -
- {(() => { - const certs = certStatus?.certs; - const allExist = certs && certs.length > 0 && certs.every(c => c.cert.exists); - const someExist = certs && certs.some(c => c.cert.exists); - if (allExist) { - return All valid; - } else if (someExist) { - return Partial; - } else if (certStatus?.configured) { - return Not provisioned; - } else { - return Not configured; - } - })()} -
- - {/* ExternalDNS note */} -
-
- - ExternalDNS -
- Per-instance -
-
-
-
-
- ); -} diff --git a/web/src/components/DashboardComponent.tsx b/web/src/components/DashboardComponent.tsx new file mode 100644 index 0000000..6270d65 --- /dev/null +++ b/web/src/components/DashboardComponent.tsx @@ -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 | 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(''); + }, + }); + + // 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: ( + <> +

+ 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 */} +
+
+ +
+
+

Dashboard

+

Infrastructure health and service prerequisites

+
+
+ + {/* 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. + + )} + {certMissing && ( + + + One or more TLS certificates are missing. Services may not be reachable over HTTPS. + + )} + {certExpiring && !certMissing && ( + + + A TLS certificate expires within 14 days. Consider renewing soon. + + )} + {ddnsFailed && ( + + + DDNS sync error: {ddnsStatus.lastError} + + )} +
+ )} + + {/* 1. Cloudflare */} + +
+
+ +
Cloudflare
+
+ {cfLoading ? ( + + ) : verification?.tokenValid ? ( + + + Connected + + ) : verification?.tokenConfigured ? ( + + + Invalid + + ) : ( + Not configured + )} +
+ + {cfLoading ? ( +
+ + Checking token... +
+ ) : verification?.tokenValid ? ( +
+ {verification.zones && verification.zones.length > 0 && ( +
+ +
+ Zones: + + {verification.zones.map((zone) => ( + + {zone.name} + + ))} + +
+
+ )} +
+ ) : ( +
+ {!editingToken ? ( +
+

+ {verification?.tokenConfigured + ? `Token invalid${verification?.tokenStatus ? ` (${verification.tokenStatus})` : ''}` + : 'An API token is required for DNS and TLS features'} +

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

{String(updateSecretsMutation.error)}

+ )} +
+ )} +
+ )} +
+ + {/* 2. Dynamic DNS */} + +
+
+ +
Dynamic DNS
+
+
+ {ddnsLoading ? ( + + ) : ddnsStatus?.enabled ? ( + + + Active + + ) : ( + Disabled + )} +
+
+ + {ddnsStatus?.enabled ? ( +
+
+
+ Public IP +
{ddnsStatus.currentIP || '--'}
+
+
+ Last checked +
+ {ddnsStatus.lastChecked ? new Date(ddnsStatus.lastChecked).toLocaleTimeString() : '--'} +
+
+
+ + {/* Record list */} + {globalConfig?.cloud?.ddns?.records?.length ? ( +
+ {globalConfig.cloud.ddns.records.map((r) => { + const rs: DdnsRecordStatus | undefined = ddnsStatus.records?.find(s => s.name === r); + return ( +
+ {rs?.ok ? ( + + ) : rs && !rs.ok ? ( + + ) : ( +
+ )} + {r} + {rs?.ok && rs.ip && ( + {rs.ip} + )} + {rs && !rs.ok && rs.error && ( + {rs.error} + )} +
+ ); + })} +
+ ) : null} + + +
+ ) : ( +

+ DDNS is not enabled. Configure it in the DDNS settings to keep DNS records in sync with your public IP. +

+ )} + + + {/* 3. Daemon Status */} + +
+
+ +
Daemon Status
+
+ {statusLoading ? ( + + ) : centralStatus ? ( + + + Running + + ) : ( + Offline + )} +
+ + {statusLoading ? ( +
+ + Loading status... +
+ ) : centralStatus ? ( +
+ {/* Service badges */} +
+ + + + +
+ + {/* Version and uptime */} +
+
+ Version +
{centralStatus.version || 'Unknown'}
+
+
+ Uptime +
{centralStatus.uptime || '--'}
+
+
+ + +
+ ) : ( +

Cannot reach the Wild Central daemon.

+ )} +
+ + {/* 4. TLS Certificates */} + {certStatus?.certs && certStatus.certs.length > 0 && ( + +
+
+ +
TLS Certificates
+
+ {(() => { + const allExist = certStatus.certs!.every(c => c.cert.exists); + const someExist = certStatus.certs!.some(c => c.cert.exists); + if (allExist) { + return All valid; + } else if (someExist) { + return Partial; + } else { + return Missing; + } + })()} +
+
+ {certStatus.certs!.map((entry) => ( +
+ {entry.domain} + {entry.cert.exists ? ( + + Valid ({entry.cert.daysLeft}d) + + ) : ( + Missing + )} +
+ ))} +
+
+ )} + + {/* 5. Gateway Router */} + +
+ +
Gateway Router
+
+
+

Ensure your router is configured for Wild Central to work correctly:

+
    +
  • + Forward DNS to Wild Central + {globalConfig?.cloud?.dnsmasq?.ip && ( + + {globalConfig.cloud.dnsmasq.ip} + + )} +
  • +
  • Forward ports 443, 80, 51820 to Wild Central
  • +
  • Set Wild Central as the primary DNS server for your LAN
  • +
+
+
+
+ ); +} + +/** 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 ( + + + {name} + + ); +} diff --git a/web/src/components/DdnsComponent.tsx b/web/src/components/DdnsComponent.tsx deleted file mode 100644 index 52e75e3..0000000 --- a/web/src/components/DdnsComponent.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Card, CardHeader, CardTitle, CardContent } from './ui/card'; -import { Button } from './ui/button'; -import { Input, Label } from './ui'; -import { Textarea } from './ui/textarea'; -import { Alert, AlertDescription } from './ui/alert'; -import { Globe, Loader2, AlertCircle, CheckCircle, XCircle, RefreshCw, Edit2 } from 'lucide-react'; -import { Badge } from './ui/badge'; -import { useConfig } from '../hooks'; -import { useDdns } from '../hooks/useDdns'; -import { secretsApi } from '../services/api'; -import type { DdnsRecordStatus } from '../services/api/networking'; - -export function DdnsComponent() { - const { config: globalConfig, updateConfig, isUpdating } = useConfig(); - const { status: ddnsStatus, isLoadingStatus: isDdnsLoading, trigger: triggerDdns, isTriggering: isDdnsTriggering } = useDdns(); - - const queryClient = useQueryClient(); - const { data: globalSecrets } = useQuery({ - queryKey: ['globalSecrets'], - queryFn: () => secretsApi.get(), - }); - const updateSecretsMutation = useMutation({ - mutationFn: (values: Record) => secretsApi.update(values), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['globalSecrets'] }); - setEditingToken(false); - setTokenValue(''); - }, - }); - const cfTokenIsSet = !!(globalSecrets as Record | undefined) - && !!(globalSecrets as { ddns?: { cloudflare?: { apiToken?: string } } })?.ddns?.cloudflare?.apiToken; - - const [editingDdnsConfig, setEditingDdnsConfig] = useState(false); - const [ddnsConfigForm, setDdnsConfigForm] = useState({ enabled: false, records: '', intervalMinutes: 5 }); - const [editingToken, setEditingToken] = useState(false); - const [tokenValue, setTokenValue] = useState(''); - - const handleDdnsConfigEdit = () => { - setDdnsConfigForm({ - enabled: globalConfig?.cloud?.ddns?.enabled ?? false, - records: (globalConfig?.cloud?.ddns?.records ?? []).join('\n'), - intervalMinutes: globalConfig?.cloud?.ddns?.intervalMinutes ?? 5, - }); - setEditingDdnsConfig(true); - }; - - const handleDdnsConfigSave = async () => { - if (!globalConfig) return; - const records = ddnsConfigForm.records.split('\n').map(r => r.trim()).filter(Boolean); - await updateConfig({ - ...globalConfig, - cloud: { - ...globalConfig.cloud, - ddns: { - enabled: ddnsConfigForm.enabled, - provider: 'cloudflare', - records, - intervalMinutes: ddnsConfigForm.intervalMinutes, - }, - }, - }); - setEditingDdnsConfig(false); - }; - - return ( -
-
-
- -
-
-

Dynamic DNS

-

Keeps your Cloudflare A records pointed at your current public IP

-
-
- - - -
- Status - {isDdnsLoading ? ( - - ) : ddnsStatus?.enabled ? ( - - - Active - - ) : ( - Disabled - )} -
-
- - {ddnsStatus?.enabled && ( -
-
-
- Public IP -
{ddnsStatus.currentIP || '—'}
-
-
- Last checked -
- {ddnsStatus.lastChecked ? new Date(ddnsStatus.lastChecked).toLocaleTimeString() : '—'} -
-
-
- {ddnsStatus.lastError && ( - - - {ddnsStatus.lastError} - - )} - -
- )} -
-
- - - -
- Configuration - {!editingDdnsConfig && ( - - )} -
-
- - {editingDdnsConfig ? ( -
-
- setDdnsConfigForm(prev => ({ ...prev, enabled: e.target.checked }))} - /> - -
-
- -