DNS setup. Global config.

This commit is contained in:
2026-01-07 16:59:58 +00:00
parent 1918098baa
commit 15fbbdcf23
7 changed files with 984 additions and 89 deletions

View File

@@ -1,18 +1,140 @@
import { useState, useEffect } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Server, HardDrive, Settings, Clock, CheckCircle, BookOpen, ExternalLink, Loader2, AlertCircle, Database, FolderTree } from 'lucide-react';
import { Input, Label } from './ui';
import { Server, HardDrive, Settings, Clock, CheckCircle, BookOpen, ExternalLink, Loader2, AlertCircle, Database, FolderTree, Mail, Router, Edit2, Check, X } from 'lucide-react';
import { Badge } from './ui/badge';
import { useCentralStatus } from '../hooks/useCentralStatus';
import { useInstanceConfig, useInstanceContext } from '../hooks';
import { useInstanceConfig, useInstanceContext, useConfig } from '../hooks';
import { usePageHelp } from '../hooks/usePageHelp';
interface GlobalConfigForm {
operator?: {
email?: string;
};
cloud?: {
router?: {
ip?: string;
dynamicDns?: string;
};
};
}
export function CentralComponent() {
const { currentInstance } = useInstanceContext();
const { data: centralStatus, isLoading: statusLoading, error: statusError } = useCentralStatus();
const { config: fullConfig, isLoading: configLoading } = useInstanceConfig(currentInstance);
const { config: globalConfig, updateConfig: updateGlobalConfig, isUpdating } = useConfig();
const [editingOperator, setEditingOperator] = useState(false);
const [editingRouter, setEditingRouter] = useState(false);
const [formValues, setFormValues] = useState<GlobalConfigForm>({});
const serverConfig = fullConfig?.server as { host?: string; port?: number } | undefined;
// Sync form values when globalConfig loads
useEffect(() => {
if (globalConfig) {
setFormValues({
operator: globalConfig.operator,
cloud: {
router: globalConfig.cloud?.router,
},
});
}
}, [globalConfig]);
const handleOperatorEdit = () => {
setEditingOperator(true);
};
const handleOperatorSave = async () => {
if (!globalConfig || !formValues.operator?.email) return;
try {
await updateGlobalConfig({
...globalConfig,
operator: {
...globalConfig.operator,
email: formValues.operator.email,
},
});
setEditingOperator(false);
} catch (err) {
console.error('Failed to save operator:', err);
}
};
const handleOperatorCancel = () => {
if (globalConfig) {
setFormValues(prev => ({
...prev,
operator: globalConfig.operator,
}));
}
setEditingOperator(false);
};
const handleRouterEdit = () => {
setEditingRouter(true);
};
const handleRouterSave = async () => {
if (!globalConfig || !formValues.cloud?.router) return;
try {
await updateGlobalConfig({
...globalConfig,
cloud: {
...globalConfig.cloud,
router: formValues.cloud.router,
},
});
setEditingRouter(false);
} catch (err) {
console.error('Failed to save router:', err);
}
};
const handleRouterCancel = () => {
if (globalConfig) {
setFormValues(prev => ({
...prev,
cloud: {
...prev.cloud,
router: globalConfig.cloud?.router,
},
}));
}
setEditingRouter(false);
};
const updateFormValue = (path: string, value: string) => {
setFormValues(prev => {
const keys = path.split('.');
if (keys.length === 2 && keys[0] === 'operator') {
return {
...prev,
operator: {
...prev.operator,
[keys[1]]: value,
},
};
}
if (keys.length === 3 && keys[0] === 'cloud' && keys[1] === 'router') {
return {
...prev,
cloud: {
...prev.cloud,
router: {
...prev.cloud?.router,
[keys[2]]: value,
},
},
};
}
return prev;
});
};
usePageHelp({
title: 'What is the Central Service?',
description: (
@@ -182,6 +304,152 @@ export function CentralComponent() {
</div>
</div>
</Card>
{(globalConfig?.operator?.email || editingOperator) && (
<Card className="p-4 border-l-4 border-l-amber-500">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Mail className="h-5 w-5 text-amber-500" />
<div className="text-sm text-muted-foreground">Operator Email</div>
</div>
{!editingOperator && (
<Button
variant="ghost"
size="sm"
onClick={handleOperatorEdit}
disabled={isUpdating}
>
<Edit2 className="h-4 w-4" />
</Button>
)}
</div>
{editingOperator ? (
<div className="space-y-3">
<div>
<Label htmlFor="operator-email">Email</Label>
<Input
id="operator-email"
type="email"
value={formValues.operator?.email || ''}
onChange={(e) => updateFormValue('operator.email', e.target.value)}
placeholder="email@example.com"
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={handleOperatorCancel}
disabled={isUpdating}
>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
<Button
size="sm"
onClick={handleOperatorSave}
disabled={isUpdating || !formValues.operator?.email}
>
{isUpdating ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Check className="h-4 w-4 mr-1" />
)}
Save
</Button>
</div>
</div>
) : (
<div className="font-medium font-mono text-sm ml-7">
{globalConfig?.operator?.email}
</div>
)}
</Card>
)}
{(globalConfig?.cloud?.router || editingRouter) && (
<Card className="p-4 border-l-4 border-l-teal-500">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Router className="h-5 w-5 text-teal-500" />
<div className="text-sm text-muted-foreground">Router</div>
</div>
{!editingRouter && (
<Button
variant="ghost"
size="sm"
onClick={handleRouterEdit}
disabled={isUpdating}
>
<Edit2 className="h-4 w-4" />
</Button>
)}
</div>
{editingRouter ? (
<div className="space-y-3">
<div>
<Label htmlFor="router-ip">Router IP</Label>
<Input
id="router-ip"
value={formValues.cloud?.router?.ip || ''}
onChange={(e) => updateFormValue('cloud.router.ip', e.target.value)}
placeholder="192.168.1.1"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="router-ddns">Dynamic DNS</Label>
<Input
id="router-ddns"
value={formValues.cloud?.router?.dynamicDns || ''}
onChange={(e) => updateFormValue('cloud.router.dynamicDns', e.target.value)}
placeholder="example.ddns.com"
className="mt-1"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRouterCancel}
disabled={isUpdating}
>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
<Button
size="sm"
onClick={handleRouterSave}
disabled={isUpdating}
>
{isUpdating ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Check className="h-4 w-4 mr-1" />
)}
Save
</Button>
</div>
</div>
) : (
<div className="space-y-1 ml-7">
{globalConfig?.cloud?.router?.ip && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-16">IP:</span>
<span className="font-medium font-mono text-sm">{globalConfig.cloud.router.ip}</span>
</div>
)}
{globalConfig?.cloud?.router?.dynamicDns && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-16">DDNS:</span>
<span className="font-medium font-mono text-sm">{globalConfig.cloud.router.dynamicDns}</span>
</div>
)}
</div>
)}
</Card>
)}
</div>
</div>
</div>

View File

@@ -1,29 +1,59 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect, useState } from 'react';
import { FileText, Check, AlertCircle, Loader2 } from 'lucide-react';
import { useConfig, useMessages } from '../hooks';
import { configFormSchema, defaultConfigValues, type ConfigFormData } from '../schemas/config';
import { Message } from './Message';
import { Card, CardHeader, CardTitle, CardContent, Button, Form, FormField, FormItem, FormLabel, FormControl, FormMessage, Input } from './ui';
import { apiService } from '../services/api-legacy';
export const ConfigurationSection = () => {
const {
config,
isConfigured,
showConfigSetup,
isLoading,
isCreating,
error,
createConfig,
refetch
const {
config,
isConfigured,
showConfigSetup,
isLoading,
isCreating,
error,
createConfig,
refetch
} = useConfig();
const { messages } = useMessages();
const [detectedDefaults, setDetectedDefaults] = useState(defaultConfigValues);
const form = useForm<ConfigFormData>({
resolver: zodResolver(configFormSchema),
defaultValues: defaultConfigValues,
defaultValues: detectedDefaults,
});
// Fetch network info when component mounts and setup is shown
useEffect(() => {
if (showConfigSetup) {
apiService.getNetworkInfo()
.then(networkInfo => {
const updatedDefaults = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dns: {
ip: networkInfo.primary_ip,
},
dnsmasq: {
interface: networkInfo.primary_interface,
},
},
};
setDetectedDefaults(updatedDefaults);
form.reset(updatedDefaults);
})
.catch(err => {
console.error('Failed to detect network info:', err);
// Keep using static defaults on error
});
}
}, [showConfigSetup, form]);
const onSubmit = (data: ConfigFormData) => {
createConfig(data);
};

View File

@@ -1,77 +1,589 @@
import { Card } from './ui/card';
import { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Globe, CheckCircle, BookOpen, ExternalLink } from 'lucide-react';
import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge';
import { Textarea } from './ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import {
Globe,
CheckCircle,
XCircle,
AlertCircle,
Play,
RotateCw,
Settings,
Loader2,
Copy,
ChevronDown,
ChevronUp,
Edit,
TestTube2
} from 'lucide-react';
import { useDnsmasq } from '../hooks/useDnsmasq';
import { useConfig } from '../hooks';
import { apiService } from '../services/api-legacy';
import { usePageHelp } from '../hooks/usePageHelp';
export function DnsComponent() {
const { config: globalConfig } = useConfig();
const dnsIp = globalConfig?.cloud?.dns?.ip;
usePageHelp({
title: 'What is DNS?',
title: 'How DNS Works in Wild Cloud',
description: (
<>
<p className="mb-3 leading-relaxed">
DNS (Domain Name System) is like the "phone book" of the internet. Instead of remembering complex IP addresses
like "192.168.1.100", you can use friendly names like "my-server.local". When you type a name, DNS translates
it to the correct IP address so your devices can find each other.
The DNS service resolves domain names for your Wild Cloud instances. When running, it allows
devices on your network to access services like{' '}
<code className="bg-muted px-1 rounded">photos.cloud.local</code> instead
of remembering IP addresses.
</p>
<p className="text-sm">
Your personal cloud runs its own DNS service so devices can easily find services like "photos.home" or "media.local"
without needing to remember numbers.
<p className="leading-relaxed">
<strong>Router Setup:</strong> Configure your router's primary DNS server to{' '}
<code className="bg-muted px-1 rounded font-semibold">{dnsIp || 'the IP shown above'}</code> so
all devices on your network can resolve Wild Cloud domains automatically.
</p>
</>
),
icon: <BookOpen className="h-6 w-6 text-green-600 dark:text-green-400" />,
color: 'bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20',
actions: (
<Button
variant="outline"
size="sm"
className="text-green-700 border-green-300 hover:bg-green-100 dark:text-green-300 dark:border-green-700 dark:hover:bg-green-900/20"
>
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about DNS
</Button>
),
icon: <Globe className="h-6 w-6" />,
});
const {
status,
isLoadingStatus,
config,
isLoadingConfig,
fetchConfig,
generateConfig,
isGenerating,
generateData,
restart,
isRestarting,
restartData,
generateError,
restartError
} = useDnsmasq();
const [showAdvanced, setShowAdvanced] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [editedConfig, setEditedConfig] = useState('');
const [copiedIp, setCopiedIp] = useState(false);
const [isLoadingFromInstances, setIsLoadingFromInstances] = useState(false);
const [testingDomain, setTestingDomain] = useState<string | null>(null);
const [testType, setTestType] = useState<'internal' | 'external' | null>(null);
const [testResults, setTestResults] = useState<Record<string, {
internal?: { success: boolean; message: string };
external?: { success: boolean; message: string };
}>>({});
const isRunning = status?.status === 'active';
// Fetch config on mount to populate Configured Instances section
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
// Parse configured instance domains from config
const configuredDomains = (() => {
if (!config?.content) return [];
const lines = config.content.split('\n');
const domains: string[] = [];
for (const line of lines) {
const match = line.match(/^local=\/(.+?)\/$/);
if (match && !match[1].startsWith('internal.')) {
domains.push(match[1]);
}
}
return domains;
})();
const handleCopyIp = () => {
if (dnsIp) {
navigator.clipboard.writeText(dnsIp);
setCopiedIp(true);
setTimeout(() => setCopiedIp(false), 2000);
}
};
const handleTestDomain = async (domain: string, type: 'internal' | 'external') => {
setTestingDomain(domain);
setTestType(type);
try {
if (type === 'external') {
// Use DNS-over-HTTPS to test external resolution
const response = await fetch(`https://dns.google/resolve?name=${domain}&type=A`);
const data = await response.json();
if (data.Status === 0 && data.Answer && data.Answer.length > 0) {
const resolvedIp = data.Answer[0].data;
setTestResults(prev => ({
...prev,
[domain]: {
...prev[domain],
external: {
success: true,
message: resolvedIp
}
}
}));
} else {
setTestResults(prev => ({
...prev,
[domain]: {
...prev[domain],
external: {
success: false,
message: 'No public DNS record (LAN-only)'
}
}
}));
}
} else {
// Test internal resolution via Wild Central API
const response = await fetch(`http://localhost:5055/api/v1/network/resolve?domain=${domain}`);
const data = await response.json();
if (data.success && data.ip) {
setTestResults(prev => ({
...prev,
[domain]: {
...prev[domain],
internal: {
success: true,
message: data.ip
}
}
}));
} else {
setTestResults(prev => ({
...prev,
[domain]: {
...prev[domain],
internal: {
success: false,
message: data.error || 'Not found'
}
}
}));
}
}
} catch (error) {
setTestResults(prev => ({
...prev,
[domain]: {
...prev[domain],
[type]: {
success: false,
message: 'Test failed'
}
}
}));
} finally {
setTestingDomain(null);
setTestType(null);
}
};
const handleStart = () => {
// Generate config and apply it (overwrite=true)
generateConfig(true);
};
const handleRegenerateAndRestart = () => {
// Regenerate from instances and restart
generateConfig(true);
};
const handleLoadFromInstances = async () => {
setIsLoadingFromInstances(true);
try {
// Generate config without overwriting, load into editor
const result = await apiService.generateDnsmasqConfig(false);
const configText = result.config || result.content || '';
if (configText) {
setEditedConfig(configText);
} else {
console.error('No config content in response:', result);
}
} catch (error) {
console.error('Failed to load config from instances:', error);
} finally {
setIsLoadingFromInstances(false);
}
};
const handleShowAdvanced = () => {
if (!showAdvanced && !config) {
fetchConfig();
}
setShowAdvanced(!showAdvanced);
};
const handleEditConfig = () => {
// Load current config into editor
if (config?.content) {
setEditedConfig(config.content);
} else if (generateData?.config || generateData?.content) {
setEditedConfig(generateData.config || generateData.content || '');
}
setShowEditDialog(true);
};
const handleSaveConfig = async () => {
try {
await apiService.writeDnsmasqConfig(editedConfig);
setShowEditDialog(false);
// Refetch config to show updated content
fetchConfig();
} catch (error) {
console.error('Failed to save config:', error);
}
};
const handleSaveAndRestart = async () => {
try {
await apiService.writeDnsmasqConfig(editedConfig);
await apiService.restartDnsmasq();
setShowEditDialog(false);
// Refetch status and config
fetchConfig();
} catch (error) {
console.error('Failed to save and restart:', error);
}
};
return (
<div className="space-y-6">
{/* Main Status Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Globe className="h-6 w-6 text-primary" />
</div>
<div>
<CardTitle>DNS Service</CardTitle>
<p className="text-sm text-muted-foreground">
Local domain name resolution for your Wild Cloud
</p>
</div>
</div>
<Badge
variant={isRunning ? 'default' : 'secondary'}
className="gap-2"
>
{isLoadingStatus ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isRunning ? (
<CheckCircle className="h-4 w-4" />
) : (
<XCircle className="h-4 w-4" />
)}
{isLoadingStatus ? 'Checking...' : isRunning ? 'Running' : 'Stopped'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* DNS IP Address - Prominent Display */}
{dnsIp && (
<div className="p-6 bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/30 dark:to-cyan-950/30 rounded-lg border-2 border-blue-200 dark:border-blue-800">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Globe className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<p className="text-sm font-semibold text-blue-900 dark:text-blue-100">
DNS Server IP Address
</p>
</div>
<p className="text-xs text-blue-700 dark:text-blue-300 mb-3">
Configure your router to use this IP as the primary DNS server
</p>
<code className="text-2xl font-mono font-bold bg-white dark:bg-gray-900 px-4 py-2 rounded border border-blue-300 dark:border-blue-700 inline-block">
{dnsIp}
</code>
</div>
<Button
variant="outline"
size="lg"
onClick={handleCopyIp}
className="ml-4 border-blue-300 hover:bg-blue-100 dark:border-blue-700 dark:hover:bg-blue-900/20"
>
<Copy className="h-5 w-5 mr-2" />
{copiedIp ? 'Copied!' : 'Copy IP'}
</Button>
</div>
</div>
)}
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Globe className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">DNS Configuration</h2>
<p className="text-muted-foreground">
Manage DNS settings and domain resolution
</p>
</div>
</div>
{/* Status Details */}
{status && (
<div className="space-y-4">
<div className="p-4 bg-muted rounded-lg border">
<p className="text-sm text-muted-foreground mb-2">Configured Instances</p>
{isLoadingConfig ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : configuredDomains.length > 0 ? (
<div className="space-y-2">
{configuredDomains.map((domain) => (
<div key={domain} className="space-y-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-1">
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
<code className="text-sm font-mono">{domain}</code>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestDomain(domain, 'internal')}
disabled={testingDomain === domain && testType === 'internal'}
className="h-7 px-2"
title="Test internal DNS"
>
{testingDomain === domain && testType === 'internal' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<TestTube2 className="h-3 w-3" />
)}
<span className="ml-1 text-xs">Int</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleTestDomain(domain, 'external')}
disabled={testingDomain === domain && testType === 'external'}
className="h-7 px-2"
title="Test external DNS"
>
{testingDomain === domain && testType === 'external' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<TestTube2 className="h-3 w-3" />
)}
<span className="ml-1 text-xs">Ext</span>
</Button>
</div>
</div>
{testResults[domain] && (
<div className="ml-5 space-y-0.5">
{testResults[domain].internal && (
<div className={`text-xs flex items-center gap-1 ${testResults[domain].internal.success ? 'text-green-600' : 'text-red-600'}`}>
<span className="font-medium">Internal:</span>
<span className="font-mono">{testResults[domain].internal.message}</span>
</div>
)}
{testResults[domain].external && (
<div className={`text-xs flex items-center gap-1 ${testResults[domain].external.success ? 'text-green-600' : 'text-red-600'}`}>
<span className="font-medium">External:</span>
<span className="font-mono">{testResults[domain].external.message}</span>
</div>
)}
</div>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No instances configured</p>
)}
</div>
{status.last_restart && status.last_restart !== '0001-01-01T00:00:00Z' && (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Last restart:</span>
<span className="font-mono">
{new Date(status.last_restart).toLocaleString()}
</span>
</div>
)}
</div>
)}
<div className="space-y-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-sm">Local resolution: Active</span>
</div>
<div className="mt-4">
<h4 className="font-medium mb-2">DNS Status</h4>
<p className="text-sm text-muted-foreground">
DNS service is running and resolving domains correctly.
</p>
{/* Action Buttons */}
<div className="flex gap-2">
{!isRunning ? (
<Button
onClick={handleStart}
disabled={isGenerating}
className="gap-2"
size="lg"
>
{isGenerating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
Start DNS
</Button>
) : (
<Button
onClick={() => restart()}
disabled={isRestarting}
variant="outline"
className="gap-2"
>
{isRestarting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCw className="h-4 w-4" />
)}
Restart
</Button>
)}
</div>
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={() => console.log('Test DNS')}>
Test DNS
</Button>
<Button onClick={() => console.log('Configure DNS')}>
Configure
</Button>
</div>
</div>
{/* Success Messages */}
{restartData && (
<Alert className="border-green-500 bg-green-50 dark:bg-green-950">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-800 dark:text-green-200">
{restartData.message || 'DNS service restarted successfully'}
</AlertDescription>
</Alert>
)}
{/* Error Messages */}
{generateError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to generate config: {generateError.message}
</AlertDescription>
</Alert>
)}
{restartError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to restart service: {restartError.message}
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* Advanced Section */}
<Card>
<CardHeader>
<Button
variant="ghost"
className="w-full justify-between p-0 h-auto"
onClick={handleShowAdvanced}
>
<CardTitle className="text-lg">Advanced Configuration</CardTitle>
{showAdvanced ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</Button>
</CardHeader>
{showAdvanced && (
<CardContent className="space-y-4">
{/* Current Config Display */}
{config && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Current Configuration</p>
<code className="text-xs text-muted-foreground font-mono">
{config.config_file}
</code>
</div>
<Button
variant="outline"
size="sm"
onClick={handleEditConfig}
className="gap-2"
>
<Edit className="h-4 w-4" />
Edit
</Button>
</div>
<pre className="p-4 bg-muted rounded-md text-xs overflow-auto max-h-96 font-mono border">
{config.content}
</pre>
</div>
)}
{!isGenerating && !generateData && !config && (
<div className="text-center p-8 text-sm text-muted-foreground">
<p>Configuration preview will appear here</p>
</div>
)}
</CardContent>
)}
</Card>
{/* Edit Config Dialog */}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Edit DNS Configuration</DialogTitle>
<DialogDescription>
Modify the dnsmasq configuration. Changes will take effect after saving and restarting.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-3">
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleLoadFromInstances}
disabled={isLoadingFromInstances}
className="gap-2"
>
{isLoadingFromInstances ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Settings className="h-4 w-4" />
)}
Load from Instances
</Button>
</div>
<Textarea
value={editedConfig}
onChange={(e) => setEditedConfig(e.target.value)}
className="font-mono text-xs min-h-[400px]"
placeholder="# dnsmasq configuration"
/>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setShowEditDialog(false)}
>
Cancel
</Button>
<Button
variant="outline"
onClick={handleSaveConfig}
>
Save
</Button>
<Button
onClick={handleSaveAndRestart}
>
Save & Restart
</Button>
</DialogFooter>
<p className="text-xs text-muted-foreground text-center">
Save: Write config without restarting | Save & Restart: Write config and restart service
</p>
</DialogContent>
</Dialog>
</div>
);
}
}

View File

@@ -30,7 +30,7 @@ export const useConfig = () => {
}, [configQuery.data]);
const createConfigMutation = useMutation<CreateConfigResponse, Error, Config>({
mutationFn: apiService.createConfig,
mutationFn: (config) => apiService.createConfig(config),
onSuccess: () => {
// Invalidate and refetch config after successful creation
queryClient.invalidateQueries({ queryKey: ['config'] });
@@ -38,6 +38,14 @@ export const useConfig = () => {
},
});
const updateConfigMutation = useMutation<CreateConfigResponse, Error, Config>({
mutationFn: (config) => apiService.updateConfig(config),
onSuccess: () => {
// Invalidate and refetch config after successful update
queryClient.invalidateQueries({ queryKey: ['config'] });
},
});
return {
config: configQuery.data?.config || null,
isConfigured: configQuery.data?.configured || false,
@@ -45,8 +53,10 @@ export const useConfig = () => {
setShowConfigSetup,
isLoading: configQuery.isLoading,
isCreating: createConfigMutation.isPending,
error: configQuery.error || createConfigMutation.error,
isUpdating: updateConfigMutation.isPending,
error: configQuery.error || createConfigMutation.error || updateConfigMutation.error,
createConfig: createConfigMutation.mutate,
updateConfig: updateConfigMutation.mutateAsync,
refetch: configQuery.refetch,
};
};

View File

@@ -1,33 +1,62 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import { apiService } from '../services/api-legacy';
interface DnsmasqResponse {
status: string;
}
import type { DnsmasqStatus, DnsmasqConfigResponse, StatusResponse } from '../types';
export const useDnsmasq = () => {
const [dnsmasqConfig, setDnsmasqConfig] = useState<string>('');
// Query for status
const statusQuery = useQuery<DnsmasqStatus>({
queryKey: ['dnsmasq', 'status'],
queryFn: () => apiService.getDnsmasqStatus(),
refetchInterval: 5000, // Poll every 5 seconds
});
const generateConfigMutation = useMutation<string>({
mutationFn: apiService.getDnsmasqConfig,
onSuccess: (data) => {
setDnsmasqConfig(data);
// Query for config
const configQuery = useQuery<DnsmasqConfigResponse>({
queryKey: ['dnsmasq', 'config'],
queryFn: () => apiService.getDnsmasqConfig(),
enabled: false, // Only fetch when explicitly called
});
// Mutation for generating config (with optional overwrite)
const generateMutation = useMutation<DnsmasqConfigResponse, Error, boolean>({
mutationFn: (overwrite = false) => apiService.generateDnsmasqConfig(overwrite),
onSuccess: () => {
statusQuery.refetch();
configQuery.refetch();
},
});
const restartMutation = useMutation<DnsmasqResponse>({
mutationFn: apiService.restartDnsmasq,
// Mutation for restarting service
const restartMutation = useMutation<StatusResponse>({
mutationFn: () => apiService.restartDnsmasq(),
onSuccess: () => {
statusQuery.refetch();
},
});
return {
dnsmasqConfig,
generateConfig: generateConfigMutation.mutate,
isGenerating: generateConfigMutation.isPending,
generateError: generateConfigMutation.error,
// Status
status: statusQuery.data,
isLoadingStatus: statusQuery.isLoading,
statusError: statusQuery.error,
refetchStatus: statusQuery.refetch,
// Config
config: configQuery.data,
isLoadingConfig: configQuery.isLoading,
configError: configQuery.error,
fetchConfig: configQuery.refetch,
// Generate
generateConfig: generateMutation.mutate,
generateData: generateMutation.data,
isGenerating: generateMutation.isPending,
generateError: generateMutation.error,
// Restart
restart: restartMutation.mutate,
restartData: restartMutation.data,
isRestarting: restartMutation.isPending,
restartError: restartMutation.error,
restartData: restartMutation.data,
};
};

View File

@@ -1,4 +1,4 @@
import type { Status, ConfigResponse, Config, HealthResponse, StatusResponse } from '../types';
import type { Status, ConfigResponse, Config, HealthResponse, StatusResponse, NetworkInfo, DnsmasqStatus, DnsmasqConfigResponse } from '../types';
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055';
@@ -71,8 +71,29 @@ class ApiService {
});
}
async getDnsmasqConfig(): Promise<string> {
return this.requestText('/api/v1/dnsmasq/config');
async getDnsmasqStatus(): Promise<DnsmasqStatus> {
return this.request<DnsmasqStatus>('/api/v1/dnsmasq/status');
}
async getDnsmasqConfig(): Promise<DnsmasqConfigResponse> {
return this.request<DnsmasqConfigResponse>('/api/v1/dnsmasq/config');
}
async writeDnsmasqConfig(content: string): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/dnsmasq/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content }),
});
}
async generateDnsmasqConfig(overwrite: boolean = false): Promise<DnsmasqConfigResponse> {
const url = overwrite ? '/api/v1/dnsmasq/generate?overwrite=true' : '/api/v1/dnsmasq/generate';
return this.request<DnsmasqConfigResponse>(url, {
method: 'POST'
});
}
async restartDnsmasq(): Promise<StatusResponse> {
@@ -81,6 +102,10 @@ class ApiService {
});
}
async getNetworkInfo(): Promise<NetworkInfo> {
return this.request<NetworkInfo>('/api/v1/network/info');
}
async downloadPXEAssets(): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/pxe/assets', {
method: 'POST'

View File

@@ -84,4 +84,25 @@ export interface HealthResponse {
export interface StatusResponse {
status: string;
message?: string;
}
export interface DnsmasqStatus {
status: string;
pid: number;
config_file: string;
instances_configured: number;
last_restart: string;
}
export interface DnsmasqConfigResponse {
config_file: string;
content: string;
message?: string;
config?: string;
}
export interface NetworkInfo {
primary_ip: string;
primary_interface: string;
}