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:
@@ -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(); });
|
||||
|
||||
@@ -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 & 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 & 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 & 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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 && (
|
||||
<> · 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 && (
|
||||
<> · 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> · {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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user