refactor: Network Services rebuilt, DHCP decommissioned, infra cleanup

Network Services page is rebuilt around real API data: GET /api/dns/overview
returns provider-aware records; per-service Cloudflare sync is exposed via
POST /api/ddns/sync; effective domain is displayed so operators can verify
what external name resolves to the cell; NTP status reflects the actual
systemd-timesyncd state rather than a hardcoded boolean.

DHCP is fully decommissioned: the cell-dhcp container is removed from
docker-compose.yml, DHCP methods are stripped from network_manager, the
setup_cell script no longer seeds DHCP config, and the Settings DHCP field
is gone. DHCP was never a PIC responsibility and the container was consuming
resources for no benefit.

Dead code removed: api/config.py (superseded by config_manager), the
standalone Email/Calendar/Files pages (these are now optional store services
and do not need dedicated pages). api/constants.py is introduced to hold
RESERVED_SUBDOMAINS in one place rather than scattered literals.

Docker resource limits (mem_limit, cpus, pids_limit) are added to all
compose services so a runaway process cannot starve the host.

Makefile gains a warning before the backup target so operators are not
surprised by the archive path. Settings same/accept state fix ensures
the Cell Identity section correctly shows the accept/discard banner and
does not flash a false-positive change indicator on first load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 08:50:00 -04:00
parent 6232ef23a9
commit c41cadafb4
16 changed files with 575 additions and 1059 deletions
@@ -185,6 +185,64 @@ describe('Cell Identity — availability check', () => {
});
});
describe('Cell Identity — reverting a change clears the pending state', () => {
beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
it('typing a new cell_name then reverting to the saved value calls setDirty("identity", false)', async () => {
await renderSettings();
mockSetDirty.mockClear();
fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
expect(mockSetDirty).toHaveBeenLastCalledWith('identity', true);
fireEvent.change(screen.getByDisplayValue('pic2'), { target: { value: 'pic1' } });
expect(mockSetDirty).toHaveBeenLastCalledWith('identity', false);
});
it('reverting cell_name does NOT trigger a spurious auto-save', async () => {
await renderSettings();
fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
fireEvent.change(screen.getByDisplayValue('pic2'), { target: { value: 'pic1' } });
// Advance well past both debounces (availability 900 ms + auto-save 800 ms)
await act(async () => { vi.advanceTimersByTime(2500); });
await act(async () => { await Promise.resolve(); });
expect(mockUpdateConfig).not.toHaveBeenCalled();
});
it('reverting ip_range within the auto-save debounce cancels the save and clears dirty', async () => {
await renderSettings();
mockSetDirty.mockClear();
fireEvent.change(screen.getByDisplayValue('172.20.0.0/16'), { target: { value: '10.0.0.0/8' } });
expect(mockSetDirty).toHaveBeenLastCalledWith('identity', true);
// Revert before the 800 ms auto-save fires
await act(async () => { vi.advanceTimersByTime(400); });
fireEvent.change(screen.getByDisplayValue('10.0.0.0/8'), { target: { value: '172.20.0.0/16' } });
expect(mockSetDirty).toHaveBeenLastCalledWith('identity', false);
await act(async () => { vi.advanceTimersByTime(2000); });
await act(async () => { await Promise.resolve(); });
expect(mockUpdateConfig).not.toHaveBeenCalled();
});
it('modifying cell_name again after a revert re-enters the pending state', async () => {
await renderSettings();
mockSetDirty.mockClear();
fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic2' } });
fireEvent.change(screen.getByDisplayValue('pic2'), { target: { value: 'pic1' } });
fireEvent.change(screen.getByDisplayValue('pic1'), { target: { value: 'pic3' } });
expect(mockSetDirty).toHaveBeenLastCalledWith('identity', true);
});
});
describe('Cell Identity — Accept path (saveIdentity called by flusher)', () => {
beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); vi.clearAllMocks(); defaultMocks(); });
afterEach(async () => { await act(async () => { vi.runAllTimers(); }); vi.useRealTimers(); vi.resetModules(); });
-187
View File
@@ -1,187 +0,0 @@
import { useState, useEffect } from 'react';
import { Calendar as CalendarIcon, Users, Wifi, Copy, CheckCheck } from 'lucide-react';
import { calendarAPI } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-32 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
function Calendar() {
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {} } = useConfig();
const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain;
const proto = domain_mode === 'lan' ? 'http' : 'https';
const cellHost = `calendar.${svcDomain}`;
const calendarIp = service_ips.vip_calendar || '172.20.0.21';
const dnsIp = service_ips.dns || '172.20.0.3';
const calendarPort = service_configs.calendar?.port ?? 5232;
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchCalendarData();
}, []);
const fetchCalendarData = async () => {
try {
const [usersResponse, statusResponse] = await Promise.all([
calendarAPI.getUsers(),
calendarAPI.getStatus()
]);
setUsers(usersResponse.data);
setStatus(statusResponse.data);
} catch (error) {
console.error('Failed to fetch calendar data:', error);
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Calendar &amp; Contacts</h1>
<p className="mt-2 text-gray-600">Radicale CalDAV / CardDAV server</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Connection Info */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Connect your device</h3>
</div>
<p className="text-xs text-gray-500 mb-3">
Use these settings in your calendar / contacts app (iOS, Android, Thunderbird, etc.)
</p>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server URL" value={`${proto}://${cellHost}`} />
<InfoRow label="CalDAV path" value={`${proto}://${cellHost}/`} />
<InfoRow label="CardDAV path" value={`${proto}://${cellHost}/`} />
<InfoRow label="Port" value={domain_mode === 'lan' ? '80' : '443'} />
<InfoRow label="Direct IP" value={calendarIp} />
<InfoRow label="Direct port" value={String(calendarPort)} />
<InfoRow label="Protocol" value="HTTP (CalDAV/CardDAV)" />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN connection. DNS server must be set to <span className="font-mono">{dnsIp}</span>.
</p>
</div>
{/* iOS / Android quick guide */}
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Quick setup guide</h3>
</div>
<div className="space-y-3 text-sm text-gray-700">
<div>
<p className="font-medium text-gray-900 mb-1">iOS (Settings Calendar Accounts)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Add Account Other Add CalDAV Account</li>
<li>Server: <span className="font-mono">{cellHost}</span></li>
<li>Enter username &amp; password</li>
<li>For contacts: Add CardDAV Account, same server</li>
</ol>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Android (DAVx⁵ app)</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Install DAVx⁵ from Play Store / F-Droid</li>
<li>Login with URL: <span className="font-mono">{proto}://{cellHost}/</span></li>
<li>Select calendars &amp; address books to sync</li>
</ol>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Thunderbird</p>
<ol className="list-decimal ml-4 space-y-0.5 text-xs text-gray-600">
<li>Calendar New Calendar On the Network</li>
<li>Format: CalDAV, Location: <span className="font-mono">{proto}://{cellHost}/</span></li>
</ol>
</div>
</div>
</div>
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<CalendarIcon className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Radicale:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CalDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">CardDAV:</span>
<span className="text-sm font-medium text-success-600">Active</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
<div className="card">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Calendar Users</h3>
</div>
<div className="space-y-2">
{users.length > 0 ? (
users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.calendars || 0} calendars</span>
</div>
))
) : (
<p className="text-gray-500 text-sm">No calendar users configured</p>
)}
</div>
</div>
</div>
</div>
);
}
export default Calendar;
-174
View File
@@ -1,174 +0,0 @@
import { useState, useEffect } from 'react';
import { Mail, Users, Wifi, Copy, CheckCheck } from 'lucide-react';
import { emailAPI } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
function Email() {
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {} } = useConfig();
const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain;
const proto = domain_mode === 'lan' ? 'http' : 'https';
const cellHost = `mail.${svcDomain}`;
const emailCfg = service_configs.email || {};
const mailIp = service_ips.vip_mail || '172.20.0.23';
const dnsIp = service_ips.dns || '172.20.0.3';
const imapPort = emailCfg.imap_port ?? 993;
const smtpPort = emailCfg.smtp_port ?? 25;
const webmailPort = emailCfg.webmail_port ?? 8888;
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchEmailData();
}, []);
const fetchEmailData = async () => {
try {
const [usersResponse, statusResponse] = await Promise.all([
emailAPI.getUsers(),
emailAPI.getStatus()
]);
setUsers(usersResponse.data);
setStatus(statusResponse.data);
} catch (error) {
console.error('Failed to fetch email data:', error);
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
<p className="mt-2 text-gray-600">Postfix (SMTP) + Dovecot (IMAP)</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Incoming mail */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Incoming mail (IMAP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={cellHost} />
<InfoRow label="Port" value={String(imapPort)} />
<InfoRow label="Security" value="SSL/TLS" />
<InfoRow label="Direct IP" value={mailIp} />
</div>
</div>
{/* Outgoing mail */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Outgoing mail (SMTP)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="Server" value={cellHost} />
<InfoRow label="Port" value={String(smtpPort)} />
<InfoRow label="Security" value="STARTTLS" />
<InfoRow label="Auth" value="Username + Password" />
</div>
</div>
{/* Webmail */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Webmail</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://mail.${svcDomain}`} />
<InfoRow label="Alt URL" value={`${proto}://webmail.${svcDomain}`} />
<InfoRow label="Direct IP" value={`http://${mailIp}`} />
<InfoRow label="Direct port" value={String(webmailPort)} />
</div>
<p className="text-xs text-gray-400 mt-3">
Requires VPN + DNS set to <span className="font-mono">{dnsIp}</span>.
</p>
</div>
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<Mail className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Postfix (SMTP):</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Dovecot (IMAP):</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
<div className="card lg:col-span-2">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Email Accounts</h3>
</div>
<div className="space-y-2">
{users.length > 0 ? (
users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.domain}</span>
</div>
))
) : (
<p className="text-gray-500 text-sm">No email accounts configured</p>
)}
</div>
</div>
</div>
</div>
);
}
export default Email;
-186
View File
@@ -1,186 +0,0 @@
import { useState, useEffect } from 'react';
import { FolderOpen, Users, HardDrive, Wifi, Copy, CheckCheck } from 'lucide-react';
import { fileAPI } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<button onClick={copy} className="ml-2 text-gray-400 hover:text-gray-600" title="Copy">
{copied ? <CheckCheck className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
function InfoRow({ label, value }) {
return (
<div className="flex items-center justify-between py-1.5 border-b border-gray-100 last:border-0">
<span className="text-sm text-gray-500 w-36 shrink-0">{label}</span>
<div className="flex items-center">
<span className="text-sm font-mono font-medium text-gray-800">{value}</span>
<CopyButton text={value} />
</div>
</div>
);
}
function Files() {
const { domain = 'cell', effective_domain, domain_mode = 'lan', service_ips = {}, service_configs = {} } = useConfig();
const svcDomain = (domain_mode !== 'lan' && effective_domain) ? effective_domain : domain;
const proto = domain_mode === 'lan' ? 'http' : 'https';
const filesHost = `files.${svcDomain}`;
const webdavHost = `webdav.${svcDomain}`;
const filesIp = service_ips.vip_files || '172.20.0.22';
const webdavIp = service_ips.vip_webdav || '172.20.0.24';
const filesCfg = service_configs.files || {};
const webdavPort = filesCfg.port ?? 8080;
const filegatorPort = filesCfg.manager_port ?? 8082;
const [users, setUsers] = useState([]);
const [status, setStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchFilesData();
}, []);
const fetchFilesData = async () => {
try {
const [usersResponse, statusResponse] = await Promise.all([
fileAPI.getUsers(),
fileAPI.getStatus()
]);
setUsers(usersResponse.data);
setStatus(statusResponse.data);
} catch (error) {
console.error('Failed to fetch files data:', error);
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
<p className="mt-2 text-gray-600">FileGator (browser) + WebDAV (native clients)</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* File Manager */}
<div className="card">
<div className="flex items-center mb-4">
<Wifi className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Web file manager</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://${filesHost}`} />
<InfoRow label="Direct IP" value={`http://${filesIp}`} />
<InfoRow label="Direct port" value={String(filegatorPort)} />
</div>
<p className="text-xs text-gray-400 mt-3">
Browser-based file manager. Requires VPN.
</p>
</div>
{/* WebDAV */}
<div className="card">
<div className="flex items-center mb-4">
<FolderOpen className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">WebDAV (mount as drive)</h3>
</div>
<div className="bg-gray-50 rounded-lg px-4 py-2">
<InfoRow label="URL" value={`${proto}://${webdavHost}`} />
<InfoRow label="Direct IP" value={`http://${webdavIp}`} />
<InfoRow label="Direct port" value={String(webdavPort)} />
<InfoRow label="Auth" value="Basic (user / password)" />
</div>
<p className="text-xs text-gray-400 mt-3">
Mount in macOS Finder, Windows Explorer, or any WebDAV client.
</p>
</div>
{/* OS quick guide */}
<div className="card">
<div className="flex items-center mb-4">
<HardDrive className="h-5 w-5 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Mount as network drive</h3>
</div>
<div className="space-y-3 text-sm">
<div>
<p className="font-medium text-gray-900 mb-1">macOS (Finder)</p>
<p className="text-xs text-gray-600">Go Connect to Server <span className="font-mono">{proto}://{webdavHost}</span></p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Windows</p>
<p className="text-xs text-gray-600">Map Network Drive <span className="font-mono">\\{webdavHost}\DavWWWRoot</span> or use <span className="font-mono">{proto}://{webdavHost}</span> in "Connect to a Web Site"</p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">iOS (Files app)</p>
<p className="text-xs text-gray-600">Files ... Connect to Server <span className="font-mono">{proto}://{webdavHost}</span></p>
</div>
<div>
<p className="font-medium text-gray-900 mb-1">Android</p>
<p className="text-xs text-gray-600">Use <strong>Solid Explorer</strong> or <strong>FX File Explorer</strong> Add cloud WebDAV <span className="font-mono">{proto}://{webdavHost}</span></p>
</div>
</div>
</div>
{/* Status */}
<div className="card">
<div className="flex items-center mb-4">
<HardDrive className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
</div>
{status ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">FileGator:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">WebDAV:</span>
<span className="text-sm font-medium text-success-600">Running</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">Status unavailable</p>
)}
</div>
{/* Users */}
{users.length > 0 && (
<div className="card lg:col-span-2">
<div className="flex items-center mb-4">
<Users className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Storage Users</h3>
</div>
<div className="space-y-2">
{users.map((user, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{user.username}</span>
<span className="text-sm text-gray-500">{user.storage_used || '0'} MB</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
export default Files;
+259 -138
View File
@@ -1,138 +1,259 @@
import { useState, useEffect } from 'react';
import { Network, Server, Clock } from 'lucide-react';
import { networkAPI, cellAPI } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
function NetworkServices() {
const { domain = 'cell' } = useConfig();
const [dnsRecords, setDnsRecords] = useState([]);
const [dhcpLeases, setDhcpLeases] = useState([]);
const [ntpStatus, setNtpStatus] = useState(null);
const [networkConfig, setNetworkConfig] = useState({});
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchNetworkData();
}, []);
const fetchNetworkData = async () => {
try {
const [dnsResponse, dhcpResponse, ntpResponse, cfgResponse] = await Promise.all([
networkAPI.getDNSRecords(),
networkAPI.getDHCPLeases(),
networkAPI.getNTPStatus(),
cellAPI.getConfig(),
]);
setDnsRecords(dnsResponse.data);
setDhcpLeases(dhcpResponse.data);
setNtpStatus(ntpResponse.data);
setNetworkConfig(cfgResponse.data?.service_configs?.network || {});
} catch (error) {
console.error('Failed to fetch network data:', error);
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Network Services</h1>
<p className="mt-2 text-gray-600">
DNS zone: <span className="font-mono font-medium text-gray-800">{domain}</span>
{networkConfig.dhcp_range && (
<> &middot; DHCP: <span className="font-mono font-medium text-gray-800">{networkConfig.dhcp_range}</span></>
)}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* DNS Records */}
<div className="card">
<div className="flex items-center mb-4">
<Network className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">DNS Records</h3>
</div>
<div className="space-y-2">
{dnsRecords.length > 0 ? (
dnsRecords.map((record, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<div>
<span className="text-sm font-medium">{record.name}</span>
<span className="text-xs text-gray-400 ml-1">.{record.zone}</span>
</div>
<span className="text-sm font-mono text-gray-600">{record.value}</span>
</div>
))
) : (
<p className="text-gray-500 text-sm">No DNS records configured</p>
)}
</div>
</div>
{/* DHCP Leases */}
<div className="card">
<div className="flex items-center mb-4">
<Server className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">DHCP Leases</h3>
</div>
{networkConfig.dhcp_range && (
<p className="text-xs text-gray-400 mb-2">Range: {networkConfig.dhcp_range}</p>
)}
<div className="space-y-2">
{dhcpLeases.length > 0 ? (
dhcpLeases.map((lease, index) => (
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{lease.hostname || 'Unknown'}</span>
<span className="text-sm text-gray-500">{lease.ip}</span>
</div>
))
) : (
<p className="text-gray-500 text-sm">No active DHCP leases</p>
)}
</div>
</div>
{/* NTP Status */}
<div className="card">
<div className="flex items-center mb-4">
<Clock className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">NTP Status</h3>
</div>
{networkConfig.ntp_servers && (
<p className="text-xs text-gray-400 mb-2">
Servers: {Array.isArray(networkConfig.ntp_servers)
? networkConfig.ntp_servers.join(', ')
: networkConfig.ntp_servers}
</p>
)}
{ntpStatus ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Status:</span>
<span className="text-sm font-medium text-success-600">Online</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Sync:</span>
<span className="text-sm font-medium text-success-600">Synchronized</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">NTP service unavailable</p>
)}
</div>
</div>
</div>
);
}
export default NetworkServices;
import { useState, useEffect } from 'react';
import { Network, Globe, Clock, RefreshCw, AlertTriangle } from 'lucide-react';
import { networkAPI, ddnsAPI } from '../services/api';
const MODE_LABELS = {
lan: 'LAN-only',
pic_ngo: 'pic.ngo DDNS',
cloudflare: 'Cloudflare',
custom: 'Custom registrar',
duckdns: 'DuckDNS',
};
function statusBadge(status) {
if (status === 'registered') {
return <span className="text-xs px-2 py-0.5 rounded bg-success-50 text-success-700">registered</span>;
}
if (status === 'unregistered') {
return <span className="text-xs px-2 py-0.5 rounded bg-yellow-50 text-yellow-700">unregistered</span>;
}
return <span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-500">{status || 'unknown'}</span>;
}
function RecordRow({ record }) {
return (
<div className="flex items-center justify-between p-2 bg-gray-50 rounded gap-2">
<div className="min-w-0">
<span className="text-sm font-mono font-medium text-gray-800 break-all">{record.name}</span>
<span className="text-xs text-gray-400 ml-2">{record.type}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-sm font-mono text-gray-600">{record.value || '—'}</span>
{record.status && statusBadge(record.status)}
</div>
</div>
);
}
function NetworkServices() {
const [overview, setOverview] = useState(null);
const [ntpStatus, setNtpStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [actionBusy, setActionBusy] = useState(false);
const [actionMsg, setActionMsg] = useState(null);
const fetchData = async () => {
try {
const [overviewResponse, ntpResponse] = await Promise.all([
networkAPI.getDNSOverview(),
networkAPI.getNTPStatus(),
]);
setOverview(overviewResponse.data);
setNtpStatus(ntpResponse.data);
} catch (error) {
console.error('Failed to fetch network data:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const runAction = async (fn, successMsg) => {
setActionBusy(true);
setActionMsg(null);
try {
await fn();
setActionMsg({ type: 'success', text: successMsg });
await fetchData();
} catch (error) {
const text = error?.response?.data?.error || 'Action failed';
setActionMsg({ type: 'error', text });
} finally {
setActionBusy(false);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (!overview) {
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Network Services</h1>
<p className="mt-4 text-gray-500">Unable to load DNS overview.</p>
</div>
);
}
const mode = overview.mode;
const modeLabel = MODE_LABELS[mode] || mode;
const ntpRunning = ntpStatus?.running === true;
const renderAction = () => {
if (mode === 'pic_ngo' || mode === 'duckdns') {
const label = mode === 'pic_ngo' ? 'Register / Update IP' : 'Update IP';
return (
<button
className="btn-primary"
disabled={actionBusy}
onClick={() => runAction(ddnsAPI.register, 'IP registration triggered')}
>
<RefreshCw className="h-4 w-4 mr-1 inline" /> {label}
</button>
);
}
if (mode === 'cloudflare') {
return (
<button
className="btn-primary"
disabled={actionBusy}
onClick={() => runAction(ddnsAPI.syncRecords, 'DNS records synced')}
>
<RefreshCw className="h-4 w-4 mr-1 inline" /> Sync now
</button>
);
}
return null;
};
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Network Services</h1>
<p className="mt-2 text-gray-600">
Provider: <span className="font-medium text-gray-800">{modeLabel}</span>
{overview.public_ip && (
<> &middot; Public IP: <span className="font-mono font-medium text-gray-800">{overview.public_ip}</span></>
)}
</p>
</div>
{actionMsg && (
<div
className={`mb-4 p-3 rounded text-sm ${
actionMsg.type === 'success'
? 'bg-success-50 text-success-700'
: 'bg-red-50 text-red-700'
}`}
>
{actionMsg.text}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Public DNS */}
<div className="card">
<div className="flex items-center mb-4">
<Globe className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Public DNS</h3>
</div>
<p className="text-xs text-gray-400 mb-3">
<span className="font-mono">{overview.effective_domain}</span> &middot; {modeLabel}
</p>
{mode === 'lan' && (
<p className="text-sm text-gray-500">LAN-only, no public DNS.</p>
)}
{mode === 'custom' && (
<div className="mb-3 p-2 rounded bg-yellow-50 text-yellow-700 text-xs flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>Not managed by PIC. Create these records at your registrar.</span>
</div>
)}
{mode !== 'lan' && (
<div className="space-y-2">
{overview.public_records.length > 0 ? (
overview.public_records.map((record, index) => (
<RecordRow key={index} record={record} />
))
) : (
<p className="text-gray-500 text-sm">No public records.</p>
)}
</div>
)}
{(mode === 'pic_ngo' || mode === 'duckdns') && overview.service_subdomains.length > 0 && (
<p className="text-xs text-gray-400 mt-3">
Service subdomains resolve via the wildcard record.
</p>
)}
{renderAction() && <div className="mt-4">{renderAction()}</div>}
</div>
{/* Internal zone */}
<div className="card">
<div className="flex items-center mb-4">
<Network className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">Internal zone</h3>
</div>
<p className="text-xs text-gray-400 mb-3">
<span className="font-mono">{overview.internal_domain}</span>
</p>
<div className="space-y-2">
{overview.internal_records.length > 0 ? (
overview.internal_records.map((record, index) => (
<RecordRow
key={index}
record={{
name: record.zone ? `${record.name}.${record.zone}` : record.name,
type: record.type,
value: record.value,
}}
/>
))
) : (
<p className="text-gray-500 text-sm">No internal records configured.</p>
)}
</div>
</div>
{/* NTP Status */}
<div className="card">
<div className="flex items-center mb-4">
<Clock className="h-6 w-6 text-primary-500 mr-2" />
<h3 className="text-lg font-medium text-gray-900">NTP Status</h3>
</div>
{ntpStatus ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-500">Status:</span>
<span
className={`text-sm font-medium ${
ntpRunning ? 'text-success-600' : 'text-red-600'
}`}
>
{ntpRunning ? 'Online' : 'Offline'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-500">Sync:</span>
<span
className={`text-sm font-medium ${
ntpRunning ? 'text-success-600' : 'text-gray-500'
}`}
>
{ntpRunning ? 'Synchronized' : 'Unknown'}
</span>
</div>
</div>
) : (
<p className="text-gray-500 text-sm">NTP service unavailable</p>
)}
</div>
</div>
</div>
);
}
export default NetworkServices;
+27 -24
View File
@@ -14,7 +14,7 @@ import { PORT_CONFLICT_FIELDS, detectPortConflicts } from '../utils/serviceConfi
const RESTORE_SERVICES = [
{ key: 'identity', label: 'Identity (cell name, domain, IP range)' },
{ key: 'network', label: 'Network (DNS, DHCP, NTP)' },
{ key: 'network', label: 'Network (DNS, NTP)' },
{ key: 'wireguard', label: 'WireGuard VPN' },
{ key: 'email', label: 'Email' },
{ key: 'calendar', label: 'Calendar & Contacts' },
@@ -131,13 +131,6 @@ function validateServiceConfig(key, data) {
};
if (key === 'network') {
port('dns_port');
if (data.dhcp_range) {
const parts = data.dhcp_range.split(',');
if (parts[0]?.trim() && !isValidIp(parts[0].trim()))
errors.dhcp_range = `Start IP is invalid`;
else if (parts[1]?.trim() && !isValidIp(parts[1].trim()))
errors.dhcp_range = `End IP is invalid`;
}
const badNtp = (data.ntp_servers || []).find(s => !isValidDomainOrIp(s));
if (badNtp !== undefined) errors.ntp_servers = `"${badNtp}" is not a valid hostname or IP`;
}
@@ -268,9 +261,6 @@ function NetworkForm({ data, onChange, errors = {} }) {
<Field label="DNS Port" error={errors.dns_port}>
<NumberInput value={data.dns_port} onChange={(v) => onChange({ ...data, dns_port: v })} min={1} max={65535} />
</Field>
<Field label="DHCP Range" hint="e.g. 10.0.0.100,10.0.0.200,12h" error={errors.dhcp_range}>
<TextInput value={data.dhcp_range} onChange={(v) => onChange({ ...data, dhcp_range: v })} placeholder="10.0.0.100,10.0.0.200,12h" />
</Field>
<Field label="NTP Servers" hint="Hostnames or IPs" error={errors.ntp_servers}>
<TagList value={data.ntp_servers || []} onChange={(v) => onChange({ ...data, ntp_servers: v })} placeholder="0.pool.ntp.org" />
</Field>
@@ -322,7 +312,7 @@ function VaultForm({ data, onChange }) {
// Service configs shown in Settings — email/calendar/files moved to their own pages
const SERVICE_DEFS = [
{ key: 'network', label: 'Network (DNS/DHCP/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, dhcp_range: '', ntp_servers: [] } },
{ key: 'network', label: 'Network (DNS/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, ntp_servers: [] } },
{ key: 'wireguard', label: 'WireGuard VPN', icon: Shield, Form: WireguardForm, defaults: { port: 51820, address: '', private_key: '' } },
{ key: 'routing', label: 'Routing & Firewall', icon: GitBranch, Form: RoutingForm, defaults: { nat_enabled: true, firewall_enabled: true } },
{ key: 'vault', label: 'Vault & Trust', icon: Lock, Form: VaultForm, defaults: { ca_configured: false, fernet_configured: false } },
@@ -338,7 +328,7 @@ function Settings() {
// identity
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
const [identityDirty, setIdentityDirty] = useState(false);
const [loadedCellName, setLoadedCellName] = useState('');
const [loadedIdentity, setLoadedIdentity] = useState({ cell_name: '', domain: '', ip_range: '' });
const [effectiveDomain, setEffectiveDomain] = useState('');
// DDNS
@@ -385,12 +375,13 @@ function Settings() {
]);
const cfg = cfgRes.data;
if (certRes?.data) setCertStatus(certRes.data);
setIdentity({
const loadedIdent = {
cell_name: cfg.cell_name || '',
domain: cfg.domain || '',
ip_range: cfg.ip_range || '',
});
setLoadedCellName(cfg.cell_name || '');
};
setIdentity(loadedIdent);
setLoadedIdentity(loadedIdent);
setEffectiveDomain(cfg.effective_domain || cfg.domain_name || cfg.domain || '');
setIdentityDirty(false);
setDomainMode(cfg.domain_mode || 'lan');
@@ -458,7 +449,7 @@ function Settings() {
if (domainMode !== 'pic_ngo') { setPicAvail(null); return; }
const name = identity.cell_name;
// No check needed when the name hasn't changed from what's already registered.
if (!name || name === loadedCellName) { setPicAvail(null); return; }
if (!name || name === loadedIdentity.cell_name) { setPicAvail(null); return; }
clearTimeout(picAvailTimerRef.current);
setPicAvail(null);
picAvailTimerRef.current = setTimeout(async () => {
@@ -471,7 +462,19 @@ function Settings() {
}
}, 900);
return () => clearTimeout(picAvailTimerRef.current);
}, [identity.cell_name, domainMode, loadedCellName]); // eslint-disable-line react-hooks/exhaustive-deps
}, [identity.cell_name, domainMode, loadedIdentity.cell_name]); // eslint-disable-line react-hooks/exhaustive-deps
// Dirty state is a comparison against the last loaded/saved values, so reverting
// an edit back to the saved value leaves the pending Accept/Discard state cleanly.
const updateIdentityField = (field, value) => {
const next = { ...identity, [field]: value };
setIdentity(next);
const dirty = next.cell_name !== loadedIdentity.cell_name
|| next.domain !== loadedIdentity.domain
|| next.ip_range !== loadedIdentity.ip_range;
setIdentityDirty(dirty);
draftConfig?.setDirty('identity', dirty);
};
const saveIdentity = useCallback(async () => {
if (ipRangeError || cellNameError || domainError) return;
@@ -482,7 +485,7 @@ function Settings() {
try {
const res = await cellAPI.updateConfig(identity);
setIdentityDirty(false);
setLoadedCellName(identity.cell_name);
setLoadedIdentity(identity);
draftConfig?.setDirty('identity', false);
if (res.data.warnings?.length) res.data.warnings.forEach((w) => toast(w, 'warning'));
// Refresh to get updated domain_name after DDNS registration
@@ -651,10 +654,10 @@ function Settings() {
if (ipRangeError || cellNameError || domainError) return;
// pic_ngo cell name changes require DDNS re-registration (irreversible: releases the
// old subdomain). Never auto-save these — the user must explicitly press Accept.
if (domainMode === 'pic_ngo' && identity.cell_name !== loadedCellName) return;
if (domainMode === 'pic_ngo' && identity.cell_name !== loadedIdentity.cell_name) return;
const timer = setTimeout(() => saveIdentityRef.current(), 800);
return () => clearTimeout(timer);
}, [identity, identityDirty, ipRangeError, cellNameError, domainError, domainMode, loadedCellName]); // eslint-disable-line react-hooks/exhaustive-deps
}, [identity, identityDirty, ipRangeError, cellNameError, domainError, domainMode, loadedIdentity]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const timers = SERVICE_DEFS
@@ -802,7 +805,7 @@ function Settings() {
<div className="flex items-center gap-2">
<TextInput
value={identity.cell_name}
onChange={(v) => { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
onChange={(v) => updateIdentityField('cell_name', v)}
placeholder="mycell"
maxLength={255}
/>
@@ -827,7 +830,7 @@ function Settings() {
<Field label="Local Domain" error={domainError}>
<TextInput
value={identity.domain}
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
onChange={(v) => updateIdentityField('domain', v)}
placeholder="cell"
maxLength={255}
/>
@@ -846,7 +849,7 @@ function Settings() {
<Field label="IP Range" hint="Docker bridge subnet" error={ipRangeError}>
<TextInput
value={identity.ip_range}
onChange={(v) => { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); draftConfig?.setDirty('identity', true); }}
onChange={(v) => updateIdentityField('ip_range', v)}
placeholder="172.20.0.0/16"
/>
</Field>