Replace state blob with resource-oriented API endpoints

Split /api/v1/state into dedicated resource endpoints:
- /api/v1/operator, /api/v1/central-domain
- /api/v1/ddns/config, /api/v1/dns/settings
- /api/v1/dhcp/config (moved from /dnsmasq/dhcp/)
- /api/v1/nftables/config, /api/v1/haproxy/routes

Each page now talks to its own resource instead of deep-merging
a blob. Removes useConfig hook, CentralState type, and legacy
config methods.
This commit is contained in:
2026-07-10 22:45:36 +00:00
parent 79c0c32b98
commit 79f38d2750
12 changed files with 528 additions and 335 deletions

View File

@@ -12,9 +12,8 @@ import {
import { useCloudflare } from '../hooks/useCloudflare';
import { useDdns } from '../hooks/useDdns';
import { useCentralStatus } from '../hooks/useCentralStatus';
import { useConfig } from '../hooks';
import { useVpn } from '../hooks/useVpn';
import { secretsApi } from '../services/api';
import { secretsApi, dnsSettingsApi, nftablesConfigApi } from '../services/api';
import { usePageHelp } from '../hooks/usePageHelp';
import type { DdnsRecordStatus } from '../services/api/networking';
@@ -22,8 +21,9 @@ 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 { config: globalConfig } = useConfig();
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({
@@ -379,9 +379,9 @@ export function DashboardComponent() {
<ul className="list-disc list-inside space-y-1 text-xs">
<li>
Set the router's DNS server to Wild Central
{globalConfig?.cloud?.dnsmasq?.ip && (
{dnsSettings?.ip && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">
{globalConfig.cloud.dnsmasq.ip}
{dnsSettings.ip}
</span>
)}
</li>
@@ -391,7 +391,7 @@ export function DashboardComponent() {
{ port: 443, proto: 'TCP', label: 'HTTPS' },
{ port: 80, proto: 'TCP', label: 'HTTP' },
...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []),
...((globalConfig?.cloud?.nftables?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[])
...((nftConfig?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[])
.map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })),
].map((p, i) => (
<span key={i}>

View File

@@ -5,13 +5,20 @@ import { Input, Label } from './ui';
import { Alert, AlertDescription } from './ui/alert';
import { Wifi, Loader2, Edit2, Check, X, Trash2, Plus, RotateCw, CheckCircle, AlertCircle } from 'lucide-react';
import { Badge } from './ui/badge';
import { useConfig } from '../hooks';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useDhcp } from '../hooks/useDhcp';
import { useDnsmasq } from '../hooks/useDnsmasq';
import { dhcpConfigApi, type DHCPConfig } from '../services/api/settings';
import type { DhcpStaticLease } from '../services/api/networking';
export function DhcpComponent() {
const { config: globalConfig, updateConfig, isUpdating } = useConfig();
const queryClient = useQueryClient();
const { data: dhcpConfig } = useQuery({ queryKey: ['dhcp', 'config'], queryFn: () => dhcpConfigApi.get() });
const dhcpMutation = useMutation({
mutationFn: (data: Partial<DHCPConfig>) => dhcpConfigApi.update(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dhcp', 'config'] }),
});
const isUpdating = dhcpMutation.isPending;
const { leases, isLoadingLeases, refetchLeases, addStatic, isAddingStatic, deleteStatic } = useDhcp();
const { generateConfig: generateDnsConfig } = useDnsmasq();
@@ -36,33 +43,23 @@ export function DhcpComponent() {
const handleDhcpConfigEdit = () => {
setDhcpConfigForm({
enabled: globalConfig?.cloud?.dnsmasq?.dhcp?.enabled ?? false,
rangeStart: globalConfig?.cloud?.dnsmasq?.dhcp?.rangeStart ?? '',
rangeEnd: globalConfig?.cloud?.dnsmasq?.dhcp?.rangeEnd ?? '',
leaseTime: globalConfig?.cloud?.dnsmasq?.dhcp?.leaseTime ?? '24h',
gateway: globalConfig?.cloud?.dnsmasq?.dhcp?.gateway ?? '',
enabled: dhcpConfig?.enabled ?? false,
rangeStart: dhcpConfig?.rangeStart ?? '',
rangeEnd: dhcpConfig?.rangeEnd ?? '',
leaseTime: dhcpConfig?.leaseTime ?? '24h',
gateway: dhcpConfig?.gateway ?? '',
});
setEditingDhcpConfig(true);
};
const handleDhcpConfigSave = async () => {
if (!globalConfig) return;
try {
await updateConfig({
...globalConfig,
cloud: {
...globalConfig.cloud,
dnsmasq: {
...globalConfig.cloud?.dnsmasq,
dhcp: {
enabled: dhcpConfigForm.enabled,
rangeStart: dhcpConfigForm.rangeStart,
rangeEnd: dhcpConfigForm.rangeEnd,
leaseTime: dhcpConfigForm.leaseTime,
gateway: dhcpConfigForm.gateway || undefined,
},
},
},
await dhcpMutation.mutateAsync({
enabled: dhcpConfigForm.enabled,
rangeStart: dhcpConfigForm.rangeStart,
rangeEnd: dhcpConfigForm.rangeEnd,
leaseTime: dhcpConfigForm.leaseTime,
gateway: dhcpConfigForm.gateway || undefined,
});
setEditingDhcpConfig(false);
if (dhcpConfigForm.enabled) {
@@ -104,9 +101,9 @@ export function DhcpComponent() {
<div className="flex items-center justify-between">
<CardTitle>Configuration</CardTitle>
<div className="flex items-center gap-2">
<Badge variant={globalConfig?.cloud?.dnsmasq?.dhcp?.enabled ? 'success' : 'secondary'} className="gap-1">
{globalConfig?.cloud?.dnsmasq?.dhcp?.enabled && <CheckCircle className="h-3 w-3" />}
{globalConfig?.cloud?.dnsmasq?.dhcp?.enabled ? 'Enabled' : 'Disabled'}
<Badge variant={dhcpConfig?.enabled ? 'success' : 'secondary'} className="gap-1">
{dhcpConfig?.enabled && <CheckCircle className="h-3 w-3" />}
{dhcpConfig?.enabled ? 'Enabled' : 'Disabled'}
</Badge>
{!editingDhcpConfig && (
<Button variant="outline" size="sm" onClick={handleDhcpConfigEdit} className="gap-1 h-7 text-xs">
@@ -189,24 +186,24 @@ export function DhcpComponent() {
</div>
) : (
<div className="space-y-2 text-sm">
{globalConfig?.cloud?.dnsmasq?.dhcp?.enabled ? (
{dhcpConfig?.enabled ? (
<>
<div className="flex items-center gap-4">
<div>
<span className="text-muted-foreground">Range: </span>
<span className="font-mono">
{globalConfig.cloud.dnsmasq.dhcp.rangeStart} {globalConfig.cloud.dnsmasq.dhcp.rangeEnd}
{dhcpConfig!.rangeStart} {dhcpConfig!.rangeEnd}
</span>
</div>
<div>
<span className="text-muted-foreground">Lease: </span>
<span className="font-mono">{globalConfig.cloud.dnsmasq.dhcp.leaseTime || '24h'}</span>
<span className="font-mono">{dhcpConfig!.leaseTime || '24h'}</span>
</div>
</div>
{globalConfig.cloud.dnsmasq.dhcp.gateway && (
{dhcpConfig!.gateway && (
<div>
<span className="text-muted-foreground">Gateway: </span>
<span className="font-mono">{globalConfig.cloud.dnsmasq.dhcp.gateway}</span>
<span className="font-mono">{dhcpConfig!.gateway}</span>
</div>
)}
</>

View File

@@ -7,9 +7,10 @@ import { Alert, AlertDescription } from './ui/alert';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Shield, Loader2, AlertCircle, CheckCircle, Check, X, Plus, Trash2, BookOpen, ChevronDown, ChevronUp, Lock } from 'lucide-react';
import { Badge } from './ui/badge';
import { useConfig } from '../hooks';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNftables } from '../hooks/useNftables';
import { useVpn } from '../hooks/useVpn';
import { nftablesConfigApi, haproxyRoutesApi } from '../services/api/settings';
type ExtraPort = { port: number; protocol?: string; label?: string };
@@ -38,15 +39,20 @@ interface PortEntry {
}
export function FirewallComponent() {
const { config: globalConfig, updateConfig } = useConfig();
const queryClient = useQueryClient();
const { data: nftCfg } = useQuery({ queryKey: ['nftables', 'config'], queryFn: () => nftablesConfigApi.get() });
const { data: haproxyRoutes } = useQuery({ queryKey: ['haproxy', 'routes'], queryFn: () => haproxyRoutesApi.get() });
const nftMutation = useMutation({
mutationFn: (data: Parameters<typeof nftablesConfigApi.update>[0]) => nftablesConfigApi.update(data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['nftables', 'config'] }),
});
const { status: nftablesStatus, isLoadingStatus: isNftablesLoading, interfaces, refetchStatus } = useNftables();
const { config: vpnConfig } = useVpn();
const nftCfg = globalConfig?.cloud?.nftables;
const isEnabled = nftCfg?.enabled !== false;
const currentWan = nftCfg?.wanInterface ?? '';
const currentExtraPorts: ExtraPort[] = nftCfg?.extraPorts ?? [];
const customRoutes = globalConfig?.cloud?.haproxy?.customRoutes ?? [];
const customRoutes = haproxyRoutes?.customRoutes ?? [];
const [editingWan, setEditingWan] = useState(false);
const [wanValue, setWanValue] = useState('');
@@ -121,15 +127,8 @@ export function FirewallComponent() {
const tcpPorts = allPorts.filter(p => p.protocol === 'tcp');
const udpPorts = allPorts.filter(p => p.protocol === 'udp');
async function saveNftConfig(patch: NonNullable<NonNullable<typeof globalConfig>['cloud']>['nftables']) {
if (!globalConfig) return;
await updateConfig({
...globalConfig,
cloud: {
...globalConfig.cloud,
nftables: { ...nftCfg, ...patch },
},
});
async function saveNftConfig(patch: Partial<NonNullable<typeof nftCfg>>) {
await nftMutation.mutateAsync(patch);
refetchStatus();
}

View File

@@ -1,7 +1,6 @@
export { useMessages } from './useMessages';
export { useStatus } from './useStatus';
export { useHealth } from './useHealth';
export { useConfig } from './useConfig';
export { useDnsmasq } from './useDnsmasq';
export { useCentralStatus } from './useCentralStatus';
export { useCloudflare } from './useCloudflare';

View File

@@ -1,61 +0,0 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api-legacy';
import type { CentralState, CentralStateResponse } from '../types';
interface CreateConfigResponse {
status: string;
}
/**
* Hook for managing Wild Central runtime state
* Endpoint: /api/v1/state
* File: {dataDir}/state.yaml
*/
export const useConfig = () => {
const queryClient = useQueryClient();
const [showConfigSetup, setShowConfigSetup] = useState(false);
const stateQuery = useQuery<CentralStateResponse>({
queryKey: ['state'],
queryFn: () => apiService.getConfig(),
});
// Update showConfigSetup based on query data
useEffect(() => {
if (stateQuery.data) {
setShowConfigSetup(stateQuery.data.configured === false);
}
}, [stateQuery.data]);
const createStateMutation = useMutation<CreateConfigResponse, Error, CentralState>({
mutationFn: (state) => apiService.createConfig(state),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['state'] });
setShowConfigSetup(false);
},
});
const updateStateMutation = useMutation<CreateConfigResponse, Error, CentralState>({
mutationFn: (state) => apiService.updateConfig(state),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['state'] });
// Immediately re-check daemon status so the pending-restart banner appears
queryClient.invalidateQueries({ queryKey: ['central', 'status'] });
},
});
return {
config: stateQuery.data?.state || null,
isConfigured: stateQuery.data?.configured || false,
showConfigSetup,
setShowConfigSetup,
isLoading: stateQuery.isLoading,
isCreating: createStateMutation.isPending,
isUpdating: updateStateMutation.isPending,
error: stateQuery.error || createStateMutation.error || updateStateMutation.error,
createConfig: createStateMutation.mutate,
updateConfig: updateStateMutation.mutateAsync,
refetch: stateQuery.refetch,
};
};

View File

@@ -1,7 +1,5 @@
import type {
Status,
CentralState,
CentralStateResponse,
HealthResponse,
StatusResponse,
NetworkInfo,
@@ -30,16 +28,6 @@ class ApiService {
return response.json();
}
private async requestText(endpoint: string, options?: RequestInit): Promise<string> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
}
async getStatus(): Promise<Status> {
return this.request<Status>('/api/status');
@@ -49,43 +37,6 @@ class ApiService {
return this.request<HealthResponse>('/api/v1/health');
}
// ========================================
// Global Config APIs (Wild Central level)
// Endpoint: /api/v1/state
// ========================================
async getConfig(): Promise<CentralStateResponse> {
return this.request<CentralStateResponse>('/api/v1/state');
}
async getConfigYaml(): Promise<string> {
return this.requestText('/api/v1/state/yaml');
}
async updateConfigYaml(yamlContent: string): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/state/yaml', {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: yamlContent
});
}
async createConfig(config: CentralState): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async updateConfig(config: CentralState): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/state', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async getDnsmasqStatus(): Promise<DnsmasqStatus> {
return this.request<DnsmasqStatus>('/api/v1/dnsmasq/status');
}

View File

@@ -9,3 +9,5 @@ export { certApi } from './cert';
export type { CertStatusResponse, CertInfo } from './cert';
export { cloudflareApi } from './cloudflare';
export type { CloudflareVerifyResponse, CloudflareZone } from './cloudflare';
export { operatorApi, centralDomainApi, dnsSettingsApi, ddnsConfigApi, dhcpConfigApi, nftablesConfigApi, haproxyRoutesApi } from './settings';
export type { OperatorSettings, CentralDomainSettings, DNSSettings, DDNSConfig, DHCPConfig, NftablesConfig, HAProxyCustomRoute, HAProxyRoutes } from './settings';

View File

@@ -144,15 +144,15 @@ export interface DhcpStaticLease {
export const dhcpApi = {
async getLeases(): Promise<{ leases: DhcpLease[] }> {
return apiClient.get('/api/v1/dnsmasq/dhcp/leases');
return apiClient.get('/api/v1/dhcp/leases');
},
async addStatic(lease: DhcpStaticLease): Promise<{ message: string }> {
return apiClient.post('/api/v1/dnsmasq/dhcp/static', lease);
return apiClient.post('/api/v1/dhcp/static', lease);
},
async deleteStatic(mac: string): Promise<{ message: string }> {
return apiClient.delete(`/api/v1/dnsmasq/dhcp/static/${encodeURIComponent(mac)}`);
return apiClient.delete(`/api/v1/dhcp/static/${encodeURIComponent(mac)}`);
},
};

View File

@@ -0,0 +1,121 @@
import { apiClient } from './client';
// Operator
export interface OperatorSettings {
email: string;
}
export const operatorApi = {
async get(): Promise<OperatorSettings> {
return apiClient.get('/api/v1/operator');
},
async update(data: Partial<OperatorSettings>): Promise<OperatorSettings> {
return apiClient.put('/api/v1/operator', data);
},
};
// Central Domain
export interface CentralDomainSettings {
domain: string;
}
export const centralDomainApi = {
async get(): Promise<CentralDomainSettings> {
return apiClient.get('/api/v1/central-domain');
},
async update(data: CentralDomainSettings): Promise<CentralDomainSettings> {
return apiClient.put('/api/v1/central-domain', data);
},
};
// DNS Settings
export interface DNSSettings {
ip: string;
interface: string;
}
export const dnsSettingsApi = {
async get(): Promise<DNSSettings> {
return apiClient.get('/api/v1/dns/settings');
},
async update(data: Partial<DNSSettings>): Promise<DNSSettings> {
return apiClient.put('/api/v1/dns/settings', data);
},
};
// DDNS Config
export interface DDNSConfig {
enabled: boolean;
provider?: string;
intervalMinutes?: number;
}
export const ddnsConfigApi = {
async get(): Promise<DDNSConfig> {
return apiClient.get('/api/v1/ddns/config');
},
async update(data: Partial<DDNSConfig>): Promise<DDNSConfig> {
return apiClient.put('/api/v1/ddns/config', data);
},
};
// DHCP Config
export interface DHCPConfig {
enabled: boolean;
rangeStart?: string;
rangeEnd?: string;
leaseTime?: string;
gateway?: string;
}
export const dhcpConfigApi = {
async get(): Promise<DHCPConfig> {
return apiClient.get('/api/v1/dhcp/config');
},
async update(data: Partial<DHCPConfig>): Promise<DHCPConfig> {
return apiClient.put('/api/v1/dhcp/config', data);
},
};
// nftables Config
export interface NftablesConfig {
enabled?: boolean;
wanInterface?: string;
extraPorts?: Array<{ port: number; protocol?: string; label?: string }>;
}
export const nftablesConfigApi = {
async get(): Promise<NftablesConfig> {
return apiClient.get('/api/v1/nftables/config');
},
async update(data: Partial<NftablesConfig>): Promise<NftablesConfig> {
return apiClient.put('/api/v1/nftables/config', data);
},
};
// HAProxy Custom Routes
export interface HAProxyCustomRoute {
name: string;
port: number;
backend: string;
}
export interface HAProxyRoutes {
customRoutes: HAProxyCustomRoute[];
}
export const haproxyRoutesApi = {
async get(): Promise<HAProxyRoutes> {
return apiClient.get('/api/v1/haproxy/routes');
},
async update(data: HAProxyRoutes): Promise<HAProxyRoutes> {
return apiClient.put('/api/v1/haproxy/routes', data);
},
};