AGDNS-4175 update DHCP page

This commit is contained in:
Ildar Kamalov 2026-07-09 16:51:22 +03:00
parent 4bab2a0b7b
commit 2150e16efc
37 changed files with 1250 additions and 1265 deletions

View file

@ -9,11 +9,11 @@
"form_error_mac_format": "Invalid MAC address",
"form_error_client_id_format": "ClientID must contain only numbers, lowercase letters, and hyphens",
"form_error_server_name": "Invalid server name",
"form_error_subnet": "Subnet \"%cidr%\" does not contain the IP address \"%ip%\"",
"form_error_subnet": "The IP address should be in the subnet %cidr%",
"form_error_gateway_ip": "Lease can't have the IP address of the gateway",
"out_of_range_error": "Must be out of range \"%start%\"-\"%end%\"",
"greater_range_start_error": "Must be greater than range start",
"subnet_error": "Addresses must be in one subnet",
"out_of_range_error": "The address must not be within the DHCP range %start%%end%",
"greater_range_start_error": "The end of the range must be greater than the start",
"subnet_error": "Range addresses must be in the same subnet",
"gateway_or_subnet_invalid": "Invalid subnet mask",
"response_details": "Response details",
"client_details": "Client details",
@ -747,9 +747,9 @@
"last_hour": "Last hour",
"last_hours": "| Last %count% hour | Last %count% hours",
"last_days": "| Last %count% day | Last %count% days",
"dhcp": "DHCP",
"dhcp_settings": "DHCP settings",
"dhcp_title": "DHCP server",
"dhcp_description": "Use AdGuard's own built-in DHCP server",
"dhcp_enable": "Use AdGuard's own built-in DHCP server",
"enabled_dhcp": "DHCP server enabled",
"disabled_dhcp": "DHCP server disabled",
"unavailable_dhcp": "DHCP is unavailable",
@ -764,7 +764,7 @@
"dhcp_config_saved": "DHCP configuration successfully saved",
"dhcp_not_found": "DHCP server is active",
"dhcp_found": "DHCP server cant be activated",
"dhcp_warning": "If you want to enable DHCP server anyway, make sure that there is no other active DHCP server in your network, as this may break the Internet connectivity for devices on the network!",
"dhcp_warning": "To enable this DHCP server, deactivate all other DHCP servers on your network. Otherwise, devices may lose Internet access",
"dhcp_error": "Failed to check the DHCP server. Wait a bit before retrying",
"dhcp_static_ip_error": "The DHCP server requires a static IP address. We could not confirm that this network interface has one",
"dhcp_dynamic_ip_found": "Your system uses dynamic IP address configuration for interface <strong>%interfaceName%</strong>. In order to use DHCP server, a static IP address must be set. Your current IP address is <strong>%ipAddress%</strong>. AdGuard Home will automatically set this IP address as static if you press the \"Enable DHCP server\" button.",
@ -780,13 +780,14 @@
"dhcp_reset": "This will reset the DHCP configuration. All changes will be lost",
"dhcp_form_gateway_input": "Gateway IP",
"dhcp_form_gateway_address": "Gateway IP address",
"dhcp_form_gateway_address_value": "<strong>Gateway IP address:</strong> %value%",
"dhcp_form_subnet_input": "Subnet mask",
"dhcp_form_range_title": "IP address range",
"dhcp_form_range_start": "Start IP address",
"dhcp_form_lease_title": "DHCP lease time (in seconds)",
"dhcp_interface_select": "Select DHCP interface",
"dhcp_hardware_address": "Hardware address",
"dhcp_ip_addresses": "IP addresses",
"dhcp_interface_select": "DHCP interface",
"dhcp_hardware_address_value": "<strong>Hardware address:</strong> %value%",
"dhcp_ip_addresses_value": "<strong>IP addresses:</strong> %value%",
"dhcp_table_mac_address": "MAC address",
"dhcp_table_ip_address": "IP address",
"dhcp_table_hostname": "Hostname",

View file

@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, fireEvent } from '@solidjs/testing-library';
import { DhcpToggle } from 'panel/components/Dhcp/DhcpToggle';
const mocks = vi.hoisted(() => ({
toggleDhcp: vi.fn(),
dhcpState: {
enabled: false,
interface_name: '',
processingDhcp: false,
processingConfig: false,
},
}));
vi.mock('panel/stores/dhcp', () => ({
get dhcpState() {
return mocks.dhcpState;
},
toggleDhcp: mocks.toggleDhcp,
}));
describe('DhcpToggle', () => {
beforeEach(() => vi.clearAllMocks());
it('renders with current enabled state', () => {
mocks.dhcpState = {
enabled: true,
interface_name: 'eth0',
processingDhcp: false,
processingConfig: false,
};
const { container } = render(() => <DhcpToggle selectedInterface={() => 'eth0'} />);
const input = container.querySelector('#dhcp_enabled') as HTMLInputElement;
expect(input.checked).toBe(true);
});
it('calls toggleDhcp with enabled:false + interface_name when toggled ON + calls onToggleOn', () => {
mocks.dhcpState = {
enabled: false,
interface_name: 'eth0',
processingDhcp: false,
processingConfig: false,
};
const onToggleOn = vi.fn();
const { container } = render(() => (
<DhcpToggle selectedInterface={() => 'eth0'} onToggleOn={onToggleOn} />
));
const input = container.querySelector('#dhcp_enabled') as HTMLInputElement;
fireEvent.change(input, { target: { checked: true } });
expect(mocks.toggleDhcp).toHaveBeenCalledWith(
expect.objectContaining({
enabled: false,
interface_name: 'eth0',
}),
);
expect(onToggleOn).toHaveBeenCalledOnce();
});
it('calls toggleDhcp with enabled:true when toggled OFF, does NOT call onToggleOn', () => {
mocks.dhcpState = {
enabled: true,
interface_name: 'eth0',
processingDhcp: false,
processingConfig: false,
};
const onToggleOn = vi.fn();
const { container } = render(() => (
<DhcpToggle selectedInterface={() => 'eth0'} onToggleOn={onToggleOn} />
));
const input = container.querySelector('#dhcp_enabled') as HTMLInputElement;
fireEvent.change(input, { target: { checked: false } });
expect(mocks.toggleDhcp).toHaveBeenCalledWith({ enabled: true });
expect(onToggleOn).not.toHaveBeenCalled();
});
it('is disabled when processing or no interface', () => {
mocks.dhcpState = {
enabled: false,
interface_name: '',
processingConfig: true,
processingDhcp: false,
};
const { container } = render(() => <DhcpToggle selectedInterface={() => ''} />);
const input = container.querySelector('#dhcp_enabled') as HTMLInputElement;
expect(input.disabled).toBe(true);
});
});

View file

@ -1,170 +0,0 @@
import { render, screen, fireEvent, waitFor } from '@solidjs/testing-library';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { DhcpInterfaces } from 'panel/initialState';
import { Ipv4Settings } from 'panel/components/Dhcp/blocks/Ipv4Settings';
const defaultProps = {
v4: {
gateway_ip: '',
subnet_mask: '',
range_start: '',
range_end: '',
lease_duration: 0,
},
interfaces: {
eth0: {
name: 'eth0',
flags: 'up',
gateway_ip: '192.168.1.1',
ip_addresses: ['192.168.1.1'],
ipv4_addresses: ['192.168.1.1'],
ipv6_addresses: [],
hardware_address: '00:00:00:00:00:00',
},
} as DhcpInterfaces,
selectedInterface: 'eth0',
processingConfig: false,
onSave: vi.fn(),
};
describe('Ipv4Settings', () => {
it('renders all fields', () => {
render(() => <Ipv4Settings {...defaultProps} />);
expect(screen.getByLabelText('Gateway IP address')).toBeInTheDocument();
expect(screen.getByPlaceholderText('192.168.1.2')).toBeInTheDocument();
expect(screen.getByPlaceholderText('192.168.1.254')).toBeInTheDocument();
expect(screen.getByLabelText('Subnet mask')).toBeInTheDocument();
});
it('shows error for gateway IP that is within the DHCP range on blur', async () => {
render(() => <Ipv4Settings {...defaultProps} />);
const gatewayInput = screen.getByLabelText('Gateway IP address');
const rangeStartInput = screen.getByPlaceholderText('192.168.1.2');
const rangeEndInput = screen.getByPlaceholderText('192.168.1.254');
const subnetInput = screen.getByLabelText('Subnet mask');
// Fill range fields first
fireEvent.change(rangeStartInput, { target: { value: '192.168.1.1' } });
fireEvent.change(rangeEndInput, { target: { value: '192.168.1.100' } });
fireEvent.change(subnetInput, { target: { value: '255.255.255.0' } });
// Fill gateway inside the range, then blur to trigger validation
fireEvent.change(gatewayInput, { target: { value: '192.168.1.50' } });
fireEvent.blur(gatewayInput);
// Should show out-of-range error with the range endpoints
await waitFor(() => {
expect(screen.getByText(/Must be out of range/)).toBeInTheDocument();
});
});
it('shows error for range end <= range start on blur', async () => {
render(() => <Ipv4Settings {...defaultProps} />);
const rangeStartInput = screen.getByPlaceholderText('192.168.1.2');
const rangeEndInput = screen.getByPlaceholderText('192.168.1.254');
const subnetInput = screen.getByLabelText('Subnet mask');
fireEvent.change(rangeStartInput, { target: { value: '192.168.1.100' } });
fireEvent.change(subnetInput, { target: { value: '255.255.255.0' } });
fireEvent.change(rangeEndInput, { target: { value: '192.168.1.50' } });
fireEvent.blur(rangeEndInput);
await waitFor(() => {
expect(screen.getByText('Must be greater than range start')).toBeInTheDocument();
});
});
it('shows error for invalid subnet mask on blur', async () => {
render(() => <Ipv4Settings {...defaultProps} />);
const gatewayInput = screen.getByLabelText('Gateway IP address');
const subnetInput = screen.getByLabelText('Subnet mask');
fireEvent.change(gatewayInput, { target: { value: '192.168.1.1' } });
fireEvent.change(subnetInput, { target: { value: '999.999.999.999' } });
fireEvent.blur(subnetInput);
await waitFor(() => {
expect(screen.getByText('Invalid subnet mask')).toBeInTheDocument();
});
});
it('shows error for range start outside subnet on blur', async () => {
render(() => <Ipv4Settings {...defaultProps} />);
const gatewayInput = screen.getByLabelText('Gateway IP address');
const subnetInput = screen.getByLabelText('Subnet mask');
const rangeStartInput = screen.getByPlaceholderText('192.168.1.2');
fireEvent.change(gatewayInput, { target: { value: '192.168.1.1' } });
fireEvent.change(subnetInput, { target: { value: '255.255.255.0' } });
fireEvent.change(rangeStartInput, { target: { value: '10.0.0.1' } });
fireEvent.blur(rangeStartInput);
await waitFor(() => {
expect(screen.getByText('Addresses must be in one subnet')).toBeInTheDocument();
});
});
it('calls onSave with valid data', async () => {
const onSave = vi.fn();
const user = userEvent.setup();
render(() => <Ipv4Settings {...defaultProps} onSave={onSave} />);
fireEvent.change(screen.getByLabelText('Gateway IP address'), {
target: { value: '192.168.1.1' },
});
fireEvent.change(screen.getByLabelText('Subnet mask'), {
target: { value: '255.255.255.0' },
});
fireEvent.change(screen.getByPlaceholderText('192.168.1.2'), {
target: { value: '192.168.1.2' },
});
fireEvent.change(screen.getByPlaceholderText('192.168.1.254'), {
target: { value: '192.168.1.254' },
});
fireEvent.change(screen.getByLabelText('DHCP lease time (in seconds)'), {
target: { value: '86400' },
});
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(onSave).toHaveBeenCalledWith({
gateway_ip: '192.168.1.1',
subnet_mask: '255.255.255.0',
range_start: '192.168.1.2',
range_end: '192.168.1.254',
lease_duration: 86400,
});
});
it('clears error on blur after fixing invalid value', async () => {
render(() => <Ipv4Settings {...defaultProps} />);
const rangeStartInput = screen.getByPlaceholderText('192.168.1.2');
const rangeEndInput = screen.getByPlaceholderText('192.168.1.254');
const subnetInput = screen.getByLabelText('Subnet mask');
// Trigger an invalid range end error via blur
fireEvent.change(rangeStartInput, { target: { value: '192.168.1.100' } });
fireEvent.change(subnetInput, { target: { value: '255.255.255.0' } });
fireEvent.change(rangeEndInput, { target: { value: '192.168.1.50' } });
fireEvent.blur(rangeEndInput);
await waitFor(() => {
expect(screen.getByText('Must be greater than range start')).toBeInTheDocument();
});
// Fix the value and blur again — error should clear via re-validation
fireEvent.change(rangeEndInput, { target: { value: '192.168.1.200' } });
fireEvent.blur(rangeEndInput);
await waitFor(() => {
expect(screen.queryByText('Must be greater than range start')).not.toBeInTheDocument();
});
});
});

View file

@ -33,7 +33,7 @@
.description {
font-size: var(--fs-text-t3);
line-height: var(--lh-text-t2);
line-height: var(--lh-t2-normal);
font-weight: var(--weight-regular);
p {

View file

@ -21,7 +21,7 @@
}
&:disabled {
color: var(--disabled-main-text);
color: var(--disabled-main-button);
cursor: default;
}

View file

@ -34,7 +34,7 @@ export interface TableColumn<T = any> {
export interface TableProps<T = any> {
data: T[];
columns: TableColumn<T>[];
emptyTable: JSX.Element;
emptyTable?: JSX.Element;
loading?: boolean;
class?: string;
pagination?: boolean;
@ -307,7 +307,7 @@ export const Table = <T extends Record<string, any>>(props: TableProps<T>) => {
</Show>
</div>
<Show when={!hasData()}>
<Show when={!hasData() && props.emptyTable}>
<div class={s.emptyTableWrapper}>{props.emptyTable}</div>
</Show>
</div>

View file

@ -42,10 +42,12 @@
.tabs_filled {
.button {
display: block;
display: inline-flex;
align-items: center;
border-radius: 8px;
border-bottom: none;
padding: 8px 16px;
min-height: 40px;
margin-bottom: 0;
min-width: 0;
white-space: nowrap;

View file

@ -17,6 +17,7 @@ import { DNSRewrites } from 'panel/components/FilterLists/DNSRewrites';
import { SetupGuide } from 'panel/components/SetupGuide';
import { Dashboard } from 'panel/components/Dashboard';
import { Dhcp } from 'panel/components/Dhcp';
import { LeasesPage } from 'panel/components/Dhcp/LeasesPage';
import { QueryLog } from 'panel/components/QueryLog';
import Toasts from '../Toasts';
import { THEMES } from '../../helpers/constants';
@ -133,6 +134,7 @@ const App = () => {
<Route path="/user_rules" component={UserRules} />
<Route path="/dns_rewrites" component={DNSRewrites} />
<Route path="/dhcp" component={Dhcp} />
<Route path="/dhcp/leases" component={LeasesPage} />
<Route path="/guide" component={SetupGuideRoute} />
<Route path="/logs" component={QueryLog} />
<Route path="/blocked_services/schedule" component={InactivityScheduleRoute} />

View file

@ -1,159 +0,0 @@
.title {
padding-bottom: 16px;
}
.settingsColumn {
@media (min-width: 1024px) {
max-width: 632px;
}
}
.fieldGroup {
margin-bottom: 20px;
}
.fieldLabel {
display: block;
margin-bottom: 8px;
color: var(--default-description-text);
}
.interfaceInfo {
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 2px;
}
.interfaceInfoRow {
display: flex;
flex-wrap: wrap;
gap: 0 4px;
line-height: 22px;
}
.interfaceInfoLabel {
font-weight: 600;
color: var(--default-main-text);
}
.interfaceInfoValue {
color: var(--default-main-text);
}
.interfaceInfoMore {
color: var(--default-product-icon);
cursor: pointer;
margin-left: 4px;
&:hover {
text-decoration: underline;
}
}
.actionLinks {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.actionLink {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 14px;
line-height: 20px;
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.actionLinkGreen {
composes: actionLink;
color: var(--default-product-icon);
&:hover:not(:disabled) {
text-decoration: underline;
}
}
.actionLinkOrange {
composes: actionLink;
color: var(--default-error-link);
&:hover:not(:disabled) {
text-decoration: underline;
}
}
.formGroup {
display: flex;
flex-direction: column;
gap: 16px;
}
.formField {
display: flex;
flex-direction: column;
gap: 8px;
}
.formFieldLabel {
color: var(--default-description-text);
}
.rangeRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.saveButton {
margin-top: 4px;
}
.tableFooterButtons {
display: flex;
gap: 8px;
margin-top: 16px;
padding: 0 16px;
@media (min-width: 768px) {
padding: 0;
}
}
.warning {
padding: 12px 16px;
background-color: var(--default-error-background);
border-radius: 8px;
color: var(--default-error-link);
margin-bottom: 24px;
}
.loader {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.unavailable {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
text-align: center;
gap: 12px;
}
.leaseModalForm {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px 0;
}

View file

@ -1,549 +1,227 @@
import { createSignal, createEffect, onMount, Show } from 'solid-js';
import cn from 'clsx';
import { useNavigate } from '@solidjs/router';
import { Dropdown } from 'panel/common/ui/Dropdown';
import { Icon } from 'panel/common/ui/Icon';
import { PageLoader } from 'panel/common/ui/Loader';
import { SettingRow } from 'panel/common/ui/SettingRow';
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { Button } from 'panel/common/ui/Button';
import { PageLoader } from 'panel/common/ui/Loader';
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
import { SwitchGroup } from 'panel/common/ui/SettingsGroup';
import { useDialogOpen } from 'panel/hooks/useField';
import { SETTINGS_URLS } from 'panel/helpers/constants';
import {
dhcpState,
getDhcpStatus,
getDhcpInterfaces,
findActiveDhcp,
setDhcpConfig,
toggleDhcp,
resetDhcp,
resetDhcpLeases,
addStaticLease,
removeStaticLease,
updateStaticLease,
toggleLeaseModal,
} from 'panel/stores/dhcp';
import { addErrorToast } from 'panel/stores/toasts';
import { InterfaceSelect } from './blocks/InterfaceSelect';
import { Ipv4Settings } from './blocks/Ipv4Settings';
import { Ipv6Settings } from './blocks/Ipv6Settings';
import { StaticLeasesTable } from './blocks/StaticLeasesTable';
import { DynamicLeasesTable } from './blocks/DynamicLeasesTable';
import { StaticLeaseModal } from './blocks/StaticLeaseModal';
import s from './Dhcp.module.pcss';
type LeaseData = {
mac: string;
ip: string;
hostname: string;
};
type V4Config = {
gateway_ip: string;
subnet_mask: string;
range_start: string;
range_end: string;
lease_duration: number;
};
type V6Config = {
range_start: string;
lease_duration: number;
};
type DhcpConfig = {
enabled: boolean;
interface_name: string;
v4?: V4Config;
v6?: V6Config;
};
const MODAL_TYPE = {
ADD_LEASE: 'ADD_LEASE',
EDIT_LEASE: 'EDIT_LEASE',
MAKE_STATIC: 'MAKE_STATIC',
};
const MAX_VISIBLE_IPS = 2;
import { DhcpToggle } from './blocks/DhcpToggle';
import { InterfaceSelector } from './blocks/InterfaceSelector';
import { DhcpV4Modal } from './blocks/DhcpV4Modal';
import { DhcpV6Modal } from './blocks/DhcpV6Modal';
import type { V4Config } from './blocks/DhcpV4Modal';
import type { V6Config } from './blocks/DhcpV6Modal';
import s from './styles.module.pcss';
export const Dhcp = () => {
const [confirmResetSettings, setConfirmResetSettings] = createSignal(false);
const [confirmResetLeases, setConfirmResetLeases] = createSignal(false);
const [confirmDeleteLease, setConfirmDeleteLease] = createSignal<LeaseData | null>(null);
const [showAllIps, setShowAllIps] = createSignal(false);
const [selectedInterface, setSelectedInterface] = createSignal(dhcpState.interface_name || '');
const navigate = useNavigate();
const v4Dialog = useDialogOpen();
const v6Dialog = useDialogOpen();
const resetDialog = useDialogOpen();
const [selectedInterface, setSelectedInterface] = createSignal(dhcpState.interface_name || '');
const [showAllIps, setShowAllIps] = createSignal(false);
const [menuOpen, setMenuOpen] = createSignal(false);
// Sync selectedInterface with store
createEffect(() => {
if (dhcpState.interface_name) {
setSelectedInterface(dhcpState.interface_name);
}
});
// Load data on mount
onMount(async () => {
await getDhcpStatus();
if (dhcpState.dhcp_available) {
await getDhcpInterfaces();
if (!selectedInterface() && dhcpState.interfaces) {
const firstIface = Object.keys(dhcpState.interfaces)[0];
if (firstIface) setSelectedInterface(firstIface);
}
}
});
const handleSaveV4Config = (values: V4Config) => {
setDhcpConfig({ interface_name: selectedInterface(), v4: values });
v4Dialog.closeDialog();
};
const handleSaveV6Config = (values: V6Config) => {
setDhcpConfig({ interface_name: selectedInterface(), v6: values });
v6Dialog.closeDialog();
};
const handleInterfaceChange = (name: string) => {
setSelectedInterface(name);
setShowAllIps(false);
};
const handleToggleDhcp = () => {
if (dhcpState.enabled) {
toggleDhcp({ enabled: dhcpState.enabled });
} else {
const values: DhcpConfig = {
enabled: dhcpState.enabled,
interface_name: selectedInterface(),
};
const v4 = dhcpState.v4;
const v6 = dhcpState.v6;
const enteredSomeV4Value = v4 && Object.values(v4).some(Boolean);
const enteredSomeV6Value = v6 && Object.values(v6).some(Boolean);
if (enteredSomeV4Value) {
values.v4 = v4;
}
if (enteredSomeV6Value) {
values.v6 = v6;
}
toggleDhcp(values);
}
};
const handleCheckDhcp = () => {
findActiveDhcp(selectedInterface());
};
const handleSaveV4Config = (values: V4Config) => {
setDhcpConfig({ interface_name: selectedInterface(), v4: values });
};
const handleSaveV6Config = (values: V6Config) => {
setDhcpConfig({ interface_name: selectedInterface(), v6: values });
};
const handleResetSettings = () => {
resetDhcp();
getDhcpStatus();
setConfirmResetSettings(false);
};
const handleResetLeases = () => {
resetDhcpLeases();
setConfirmResetLeases(false);
};
const handleAddStaticLease = () => {
toggleLeaseModal(MODAL_TYPE.ADD_LEASE);
};
const handleEditStaticLease = (lease: LeaseData) => {
toggleLeaseModal(MODAL_TYPE.EDIT_LEASE, lease);
};
const handleDeleteStaticLease = (lease: LeaseData) => {
setConfirmDeleteLease(lease);
};
const handleConfirmDeleteLease = () => {
const lease = confirmDeleteLease();
if (lease) {
removeStaticLease(lease);
setConfirmDeleteLease(null);
}
};
const handleMakeStatic = (lease: LeaseData) => {
toggleLeaseModal(MODAL_TYPE.MAKE_STATIC, lease);
};
const handleEditDynamicLease = (lease: LeaseData) => {
toggleLeaseModal(MODAL_TYPE.ADD_LEASE, lease);
};
const handleDeleteDynamicLease = (lease: LeaseData) => {
setConfirmDeleteLease(lease);
};
const handleRefreshLeases = () => {
getDhcpStatus();
};
const handleLeaseModalSubmit = (data: LeaseData) => {
if (dhcpState.modalType === MODAL_TYPE.EDIT_LEASE) {
updateStaticLease(data);
} else {
addStaticLease(data);
}
};
const handleLeaseModalClose = () => {
toggleLeaseModal();
};
const selectedIface = () => {
const interfaces = dhcpState.interfaces;
const sel = selectedInterface();
return interfaces && sel ? interfaces[sel] : null;
};
const allIps = () => selectedIface()?.ip_addresses || [];
const visibleIps = () => (showAllIps() ? allIps() : allIps().slice(0, MAX_VISIBLE_IPS));
const hiddenIpsCount = () => allIps().length - MAX_VISIBLE_IPS;
const enteredSomeValue = () => {
const v4 = dhcpState.v4;
const v6 = dhcpState.v6;
return (
(v4 && Object.values(v4).some(Boolean)) ||
(v6 && Object.values(v6).some(Boolean)) ||
selectedInterface()
);
};
const showWarning = () => {
createEffect(() => {
const check = dhcpState.check;
return (
if (
!dhcpState.enabled &&
check &&
(check.v4?.other_server?.found === 'yes' || check.v6?.other_server?.found === 'yes')
);
) {
addErrorToast({ error: intl.getMessage('dhcp_warning') });
}
});
const hasIpv4 = () =>
!!(dhcpState.interfaces && dhcpState.interfaces[selectedInterface()]?.ipv4_addresses);
const hasIpv6 = () =>
!!(dhcpState.interfaces && dhcpState.interfaces[selectedInterface()]?.ipv6_addresses);
const handleResetClick = () => {
setMenuOpen(false);
resetDialog.openDialog();
};
return (
<>
<Show when={dhcpState.processing || dhcpState.processingInterfaces}>
<PageLoader />
</Show>
const resetMenu = (
<div
class={cn(theme.dropdown.item, theme.dropdown.item_danger, theme.dropdown.item_large)}
onClick={handleResetClick}
>
{intl.getMessage('reset_dhcp_settings')}
</div>
);
<Show when={!dhcpState.processing && !dhcpState.dhcp_available}>
<div class={theme.layout.container}>
<div class={theme.layout.containerIn}>
const isLoaded = () => !dhcpState.processing && !dhcpState.processingInterfaces;
return (
<div class={theme.layout.container}>
<div class={cn(theme.layout.containerIn, theme.layout.containerIn_one_col)}>
<Show when={isLoaded()} fallback={<PageLoader />}>
<Show when={!dhcpState.dhcp_available}>
<div class={s.unavailable}>
<h2 class={theme.title.h4}>{intl.getMessage('unavailable_dhcp')}</h2>
<p class={theme.text.t2}>{intl.getMessage('unavailable_dhcp_desc')}</p>
</div>
</div>
</div>
</Show>
</Show>
<Show when={!dhcpState.processing && dhcpState.dhcp_available}>
<div class={theme.layout.container}>
<div class={theme.layout.containerIn}>
<h1
class={cn(
theme.layout.title,
theme.title.h4,
theme.title.h3_tablet,
s.title,
)}
>
{intl.getMessage('dhcp_settings')}
</h1>
<div class={s.settingsColumn}>
<SwitchGroup
id="dhcp_toggle"
title={intl.getMessage('dhcp_title')}
description={intl.getMessage('dhcp_description')}
checked={!!dhcpState.enabled}
onChange={handleToggleDhcp}
disabled={
dhcpState.processingDhcp ||
dhcpState.processingConfig ||
(!dhcpState.enabled && !selectedInterface())
}
>
<div class={s.fieldGroup}>
<InterfaceSelect
interfaces={dhcpState.interfaces}
selectedInterface={selectedInterface()}
enabled={!!dhcpState.enabled}
onChange={handleInterfaceChange}
/>
</div>
<Show when={selectedIface()}>
<div class={s.interfaceInfo}>
<Show when={selectedIface()?.gateway_ip}>
<div class={s.interfaceInfoRow}>
<span
class={cn(theme.text.t3, s.interfaceInfoLabel)}
>
{intl.getMessage('dhcp_form_gateway_input')}:
</span>
<span
class={cn(theme.text.t3, s.interfaceInfoValue)}
>
{selectedIface()?.gateway_ip}
</span>
</div>
</Show>
<Show when={selectedIface()?.hardware_address}>
<div class={s.interfaceInfoRow}>
<span
class={cn(theme.text.t3, s.interfaceInfoLabel)}
>
{intl.getMessage('dhcp_hardware_address')}:
</span>
<span
class={cn(theme.text.t3, s.interfaceInfoValue)}
>
{selectedIface()?.hardware_address}
</span>
</div>
</Show>
<Show when={allIps().length > 0}>
<div class={s.interfaceInfoRow}>
<span
class={cn(theme.text.t3, s.interfaceInfoLabel)}
>
{intl.getMessage('dhcp_ip_addresses')}:
</span>
<span
class={cn(theme.text.t3, s.interfaceInfoValue)}
>
{visibleIps().join(', ')}
</span>
<Show when={!showAllIps() && hiddenIpsCount() > 0}>
<span
class={cn(
theme.text.t3,
s.interfaceInfoMore,
)}
onClick={() => setShowAllIps(true)}
>
{intl.getMessage('show_more_count', {
count: hiddenIpsCount(),
})}
</span>
</Show>
</div>
</Show>
</div>
</Show>
<div class={s.actionLinks}>
<button
type="button"
class={s.actionLinkGreen}
onClick={handleCheckDhcp}
disabled={
!!dhcpState.enabled ||
!selectedInterface() ||
dhcpState.processingConfig ||
dhcpState.processingStatus
}
>
{intl.getMessage('check_dhcp_servers')}
</button>
<button
type="button"
class={s.actionLinkOrange}
onClick={() => setConfirmResetSettings(true)}
disabled={!enteredSomeValue() || dhcpState.processingConfig}
>
{intl.getMessage('reset_settings')}
</button>
</div>
</SwitchGroup>
</div>
<Show when={showWarning()}>
<div class={s.warning}>
<span class={theme.text.t2}>{intl.getMessage('dhcp_warning')}</span>
</div>
</Show>
<div class={s.settingsColumn}>
<h2
<Show when={dhcpState.dhcp_available}>
<div class={s.header}>
<h1
class={cn(
theme.layout.subtitle,
theme.title.h5,
theme.title.h4_tablet,
theme.layout.title,
theme.title.h4,
theme.title.h3_tablet,
s.title,
)}
>
{intl.getMessage('dhcp_ipv4_settings')}
</h2>
<Ipv4Settings
v4={dhcpState.v4}
interfaces={dhcpState.interfaces}
selectedInterface={selectedInterface()}
processingConfig={!!dhcpState.processingConfig}
onSave={handleSaveV4Config}
{intl.getMessage('dhcp')}
</h1>
<Dropdown
trigger="click"
position="bottomRight"
noIcon
open={menuOpen()}
onOpenChange={setMenuOpen}
menu={resetMenu}
>
<button
type="button"
class={theme.form.action}
aria-label={intl.getMessage('reset_dhcp_settings')}
>
<Icon icon="bullets" />
</button>
</Dropdown>
</div>
<DhcpToggle
selectedInterface={selectedInterface}
onToggleOn={v4Dialog.openDialog}
/>
<div class={s.interfaceSection}>
<InterfaceSelector
selectedInterface={selectedInterface}
onInterfaceChange={handleInterfaceChange}
showAllIps={showAllIps}
onShowAllIps={() => setShowAllIps(true)}
/>
</div>
<div class={s.settingsColumn}>
<h2
class={cn(
theme.layout.subtitle,
theme.title.h5,
theme.title.h4_tablet,
)}
>
{intl.getMessage('dhcp_ipv6_settings')}
</h2>
<Ipv6Settings
v6={dhcpState.v6}
interfaces={dhcpState.interfaces}
selectedInterface={selectedInterface()}
processingConfig={!!dhcpState.processingConfig}
onSave={handleSaveV6Config}
<SettingRow
id="dhcp_v4"
variant="link"
title={intl.getMessage('dhcp_ipv4_settings')}
value={dhcpState.v4?.gateway_ip || ''}
disabled={!hasIpv4()}
onClick={v4Dialog.openDialog}
/>
<SettingRow
id="dhcp_v6"
variant="link"
title={intl.getMessage('dhcp_ipv6_settings')}
value={
hasIpv6()
? dhcpState.v6?.range_start ||
intl.getMessage('dhcp_form_range_start')
: intl.getMessage('dhcp_v6_unavailable')
}
disabled={!hasIpv6()}
onClick={() => hasIpv6() && v6Dialog.openDialog()}
/>
<SettingRow
id="dhcp_leases_link"
variant="link"
title={intl.getMessage('dhcp_leases_title')}
onClick={() => navigate(SETTINGS_URLS.dhcpLeases)}
/>
</div>
<div>
<h2
class={cn(
theme.layout.subtitle,
theme.title.h5,
theme.title.h4_tablet,
)}
>
{intl.getMessage('dhcp_static_leases')}
</h2>
<div class={theme.form.group}>
<Show
when={
dhcpState.staticLeases && dhcpState.staticLeases.length > 0
}
fallback={
<div class={cn(theme.text.t1, s.emptyTable)}>
{intl.getMessage('static_dhcp_leases_not_found')}
</div>
}
>
<StaticLeasesTable
staticLeases={dhcpState.staticLeases || []}
processingDeleting={!!dhcpState.processingDeleting}
processingUpdating={!!dhcpState.processingUpdating}
onEdit={handleEditStaticLease}
onDelete={handleDeleteStaticLease}
onRefresh={handleRefreshLeases}
/>
</Show>
</div>
<div class={theme.form.buttonGroup}>
<Button
variant="primary"
size="small"
onClick={handleAddStaticLease}
class={theme.form.button}
disabled={!selectedInterface()}
>
{intl.getMessage('dhcp_add_static_lease')}
</Button>
<Button
variant="secondary"
size="small"
onClick={() => setConfirmResetLeases(true)}
class={theme.form.button}
disabled={
!selectedInterface() ||
!dhcpState.staticLeases ||
dhcpState.staticLeases.length === 0
}
>
{intl.getMessage('dhcp_reset_leases')}
</Button>
</div>
</div>
<DhcpV4Modal
open={v4Dialog.open()}
selectedInterface={selectedInterface}
onClose={v4Dialog.closeDialog}
onSave={handleSaveV4Config}
/>
<h2
class={cn(theme.layout.subtitle, theme.title.h5, theme.title.h4_tablet)}
>
{intl.getMessage('dhcp_leases')}
</h2>
<DhcpV6Modal
open={v6Dialog.open()}
selectedInterface={selectedInterface}
onClose={v6Dialog.closeDialog}
onSave={handleSaveV6Config}
/>
<div class={theme.form.group}>
<Show
when={dhcpState.leases && dhcpState.leases.length > 0}
fallback={
<div class={cn(theme.text.t1, s.emptyTable)}>
{intl.getMessage('dynamic_dhcp_leases_not_found')}
</div>
}
>
<DynamicLeasesTable
leases={dhcpState.leases || []}
processingUpdating={!!dhcpState.processingUpdating}
processingDeleting={!!dhcpState.processingDeleting}
onEdit={handleEditDynamicLease}
onDelete={handleDeleteDynamicLease}
onMakeStatic={handleMakeStatic}
onRefresh={handleRefreshLeases}
/>
</Show>
</div>
<Show when={dhcpState.isModalOpen}>
<StaticLeaseModal
isOpen={!!dhcpState.isModalOpen}
isEdit={dhcpState.modalType === MODAL_TYPE.EDIT_LEASE}
isMakeStatic={dhcpState.modalType === MODAL_TYPE.MAKE_STATIC}
initialData={dhcpState.leaseModalConfig}
processingAdding={!!dhcpState.processingAdding}
processingUpdating={!!dhcpState.processingUpdating}
staticLeases={dhcpState.staticLeases || []}
dhcpConfig={
dhcpState.v4
? {
gatewayIp: dhcpState.v4.gateway_ip,
subnetMask: dhcpState.v4.subnet_mask,
}
: undefined
}
onSubmit={handleLeaseModalSubmit}
onClose={handleLeaseModalClose}
/>
</Show>
<Show when={confirmResetSettings()}>
<Show when={resetDialog.open()}>
<ConfirmDialog
title={intl.getMessage('reset_settings')}
text={intl.getMessage('dhcp_reset')}
buttonText={intl.getMessage('reset_settings_confirm')}
cancelText={intl.getMessage('cancel')}
buttonVariant="danger"
onConfirm={handleResetSettings}
onClose={() => setConfirmResetSettings(false)}
submitDisabled={!!dhcpState.processingReset}
onConfirm={() => {
resetDhcp();
getDhcpStatus();
resetDialog.closeDialog();
}}
onClose={resetDialog.closeDialog}
/>
</Show>
<Show when={confirmResetLeases()}>
<ConfirmDialog
title={intl.getMessage('dhcp_reset_leases')}
text={intl.getMessage('dhcp_reset_leases_confirm')}
buttonText={intl.getMessage('reset_settings_confirm')}
cancelText={intl.getMessage('cancel')}
buttonVariant="danger"
onConfirm={handleResetLeases}
onClose={() => setConfirmResetLeases(false)}
/>
</Show>
<Show when={confirmDeleteLease()}>
<ConfirmDialog
title={intl.getMessage('delete_confirm')}
text={intl.getMessage('delete_confirm_desc', {
ip: confirmDeleteLease()?.ip,
})}
buttonText={intl.getMessage('delete_table_action_confirm')}
cancelText={intl.getMessage('cancel')}
buttonVariant="danger"
onConfirm={handleConfirmDeleteLease}
onClose={() => setConfirmDeleteLease(null)}
/>
</Show>
</div>
</div>
</Show>
</>
</Show>
</Show>
</div>
</div>
);
};

View file

@ -0,0 +1,70 @@
import { createSignal, Show } from 'solid-js';
import intl from 'panel/common/intl';
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
import { DynamicLeasesTable } from './DynamicLeasesTable';
import { dhcpState, removeStaticLease, toggleLeaseModal, getDhcpStatus } from 'panel/stores/dhcp';
type LeaseData = {
mac: string;
ip: string;
hostname: string;
};
export const DynamicLeasesTab = () => {
const [confirmDeleteLease, setConfirmDeleteLease] = createSignal<LeaseData | null>(null);
const handleEditDynamicLease = (lease: LeaseData) => {
toggleLeaseModal('ADD_LEASE', lease);
};
const handleDeleteDynamicLease = (lease: LeaseData) => {
setConfirmDeleteLease(lease);
};
const handleConfirmDeleteLease = () => {
const lease = confirmDeleteLease();
if (lease) {
removeStaticLease(lease);
setConfirmDeleteLease(null);
}
};
const handleMakeStatic = (lease: LeaseData) => {
toggleLeaseModal('MAKE_STATIC', lease);
};
const handleRefreshLeases = () => {
getDhcpStatus();
};
return (
<>
<Show when={dhcpState.leases && dhcpState.leases.length > 0}>
<DynamicLeasesTable
leases={dhcpState.leases || []}
processingUpdating={!!dhcpState.processingUpdating}
processingDeleting={!!dhcpState.processingDeleting}
onEdit={handleEditDynamicLease}
onDelete={handleDeleteDynamicLease}
onMakeStatic={handleMakeStatic}
onRefresh={handleRefreshLeases}
/>
</Show>
<Show when={confirmDeleteLease()}>
<ConfirmDialog
title={intl.getMessage('delete_confirm')}
text={intl.getMessage('delete_confirm_desc', {
ip: confirmDeleteLease()?.ip,
})}
buttonText={intl.getMessage('delete_table_action_confirm')}
cancelText={intl.getMessage('cancel')}
buttonVariant="danger"
onConfirm={handleConfirmDeleteLease}
onClose={() => setConfirmDeleteLease(null)}
/>
</Show>
</>
);
};

View file

@ -117,7 +117,7 @@ export const DynamicLeasesTable = (props: Props) => {
},
accessor: 'mac',
sortable: false,
fitContent: true,
width: 48,
render: (_value: unknown, row: DynamicLease) => {
const rowId = `${row.mac}-${row.ip}`;
return (
@ -230,8 +230,9 @@ export const DynamicLeasesTable = (props: Props) => {
>
<button
type="button"
class={s.actionButton}
class={cn(theme.table.action, s.actionButton)}
data-testid="dynamic-lease-actions-dropdown"
data-table-action
>
<Icon icon="bullets" color="gray" />
</button>

View file

@ -0,0 +1,186 @@
import { createMemo, createSignal, onMount, Show } from 'solid-js';
import { useSearchParams } from '@solidjs/router';
import cn from 'clsx';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { Breadcrumbs } from 'panel/common/ui/Breadcrumbs';
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
import { Dropdown } from 'panel/common/ui/Dropdown';
import { Icon } from 'panel/common/ui/Icon';
import { Tabs } from 'panel/common/ui/Tabs';
import { RoutePath } from 'panel/components/Routes/Paths';
import {
dhcpState,
addStaticLease,
updateStaticLease,
resetDhcpLeases,
toggleLeaseModal,
getDhcpStatus,
} from 'panel/stores/dhcp';
import { StaticLeasesTab } from './StaticLeasesTab';
import { DynamicLeasesTab } from './DynamicLeasesTab';
import { StaticLeaseModal } from './StaticLeaseModal';
import s from './styles.module.pcss';
const LEASE_TABS = {
STATIC: 'static',
DYNAMIC: 'dynamic',
} as const;
type LeaseData = {
mac: string;
ip: string;
hostname: string;
};
export const LeasesPage = () => {
const [searchParams, setSearchParams] = useSearchParams<{
tab?: string;
}>();
const [menuOpen, setMenuOpen] = createSignal(false);
const [confirmResetLeases, setConfirmResetLeases] = createSignal(false);
const activeTab = createMemo(() =>
searchParams.tab === LEASE_TABS.DYNAMIC ? LEASE_TABS.DYNAMIC : LEASE_TABS.STATIC,
);
const handleTabChange = (tabId: string) => {
setSearchParams({ tab: tabId }, { replace: true });
};
onMount(() => {
getDhcpStatus();
});
const handleLeaseModalSubmit = (data: LeaseData) => {
if (dhcpState.modalType === 'EDIT_LEASE') {
updateStaticLease(data);
} else {
addStaticLease(data);
}
};
const handleLeaseModalClose = () => {
toggleLeaseModal();
};
const handleResetClick = () => {
setMenuOpen(false);
setConfirmResetLeases(true);
};
const handleResetLeases = () => {
resetDhcpLeases();
setConfirmResetLeases(false);
};
const resetMenu = (
<div
class={cn(theme.dropdown.item, theme.dropdown.item_danger, theme.dropdown.item_large)}
onClick={handleResetClick}
>
{intl.getMessage('dhcp_reset_leases')}
</div>
);
return (
<div class={cn(theme.layout.container, theme.layout.container_compact)}>
<div class={theme.layout.containerIn}>
<div class={s.breadcrumbs}>
<Breadcrumbs
parentLinks={[
{
path: RoutePath.Dhcp,
title: intl.getMessage('dhcp'),
},
]}
currentTitle={intl.getMessage('dhcp_leases_title')}
/>
</div>
<div class={s.header}>
<h1 class={cn(theme.layout.title, theme.title.h4, theme.title.h3_tablet)}>
{intl.getMessage('dhcp_leases_title')}
</h1>
<div class={s.headerActions}>
<Dropdown
trigger="click"
position="bottomRight"
noIcon
open={menuOpen()}
onOpenChange={setMenuOpen}
menu={resetMenu}
>
<button
type="button"
class={s.menuButton}
aria-label={intl.getMessage('dhcp_reset_leases')}
>
<Icon icon="bullets" />
</button>
</Dropdown>
</div>
</div>
<Tabs
activeTab={activeTab()}
onTabChange={handleTabChange}
variant="filled"
class={s.tabs}
contentClass={s.tabContent}
fullWidth
tabs={[
{
id: LEASE_TABS.STATIC,
label: intl.getMessage('dhcp_static_leases'),
content: <StaticLeasesTab />,
},
{
id: LEASE_TABS.DYNAMIC,
label: intl.getMessage('dhcp_leases'),
content: <DynamicLeasesTab />,
},
]}
/>
<Show when={dhcpState.isModalOpen}>
<StaticLeaseModal
isOpen={!!dhcpState.isModalOpen}
isEdit={dhcpState.modalType === 'EDIT_LEASE'}
isMakeStatic={dhcpState.modalType === 'MAKE_STATIC'}
initialData={dhcpState.leaseModalConfig}
processingAdding={!!dhcpState.processingAdding}
processingUpdating={!!dhcpState.processingUpdating}
staticLeases={dhcpState.staticLeases || []}
dhcpConfig={
dhcpState.v4
? {
gatewayIp: dhcpState.v4.gateway_ip,
subnetMask: dhcpState.v4.subnet_mask,
}
: undefined
}
onSubmit={handleLeaseModalSubmit}
onClose={handleLeaseModalClose}
/>
</Show>
<Show when={confirmResetLeases()}>
<ConfirmDialog
title={intl.getMessage('dhcp_reset_leases')}
text={intl.getMessage('dhcp_reset_leases_confirm')}
buttonText={intl.getMessage('reset_settings_confirm')}
cancelText={intl.getMessage('cancel')}
buttonVariant="danger"
onConfirm={handleResetLeases}
onClose={() => setConfirmResetLeases(false)}
/>
</Show>
</div>
</div>
);
};

View file

@ -95,17 +95,19 @@ export const StaticLeaseModal = (props: Props) => {
};
const validateIpField = () => {
const val = ip();
const cidrVal = cidr();
const gatewayIp = props.dhcpConfig?.gatewayIp;
const err =
validateRequiredValue(ip()) ||
validateIp(ip()) ||
validateRequiredValue(val) ||
validateIp(val) ||
validateIpNotDuplicate(
props.staticLeases,
props.isEdit ? props.initialData?.ip : undefined,
)(ip()) ||
(cidr() && ip() ? validateIpv4InCidr(ip(), { cidr: cidr()! }) : undefined) ||
(props.dhcpConfig?.gatewayIp && ip()
? validateIpGateway(ip(), { gatewayIp: props.dhcpConfig.gatewayIp })
: undefined);
)(val) ||
(cidrVal && validateIpv4InCidr(val, { cidr: cidrVal })) ||
(gatewayIp && validateIpGateway(val, { gatewayIp }));
setIpError(err || '');
};
@ -152,6 +154,7 @@ export const StaticLeaseModal = (props: Props) => {
placeholder={intl.getMessage('form_enter_mac')}
errorMessage={macError()}
disabled={props.isEdit || props.isMakeStatic}
size="large"
/>
</div>
@ -167,6 +170,7 @@ export const StaticLeaseModal = (props: Props) => {
placeholder={intl.getMessage('form_enter_hostname')}
errorMessage={hostnameError()}
disabled={props.isMakeStatic}
size="large"
/>
</div>
@ -179,6 +183,7 @@ export const StaticLeaseModal = (props: Props) => {
label={intl.getMessage('dhcp_table_ip_address')}
placeholder={intl.getMessage('form_enter_ip')}
errorMessage={ipError()}
size="large"
/>
</div>
</div>

View file

@ -0,0 +1,81 @@
import { createSignal, Show } from 'solid-js';
import intl from 'panel/common/intl';
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
import { PlusButton } from 'panel/common/ui/PlusButton';
import { StaticLeasesTable } from './StaticLeasesTable';
import { dhcpState, removeStaticLease, toggleLeaseModal, getDhcpStatus } from 'panel/stores/dhcp';
import s from './styles.module.pcss';
type LeaseData = {
mac: string;
ip: string;
hostname: string;
};
export const StaticLeasesTab = () => {
const [confirmDeleteLease, setConfirmDeleteLease] = createSignal<LeaseData | null>(null);
const handleAddStaticLease = () => {
toggleLeaseModal('ADD_LEASE');
};
const handleEditStaticLease = (lease: LeaseData) => {
toggleLeaseModal('EDIT_LEASE', lease);
};
const handleDeleteStaticLease = (lease: LeaseData) => {
setConfirmDeleteLease(lease);
};
const handleConfirmDeleteLease = () => {
const lease = confirmDeleteLease();
if (lease) {
removeStaticLease(lease);
setConfirmDeleteLease(null);
}
};
const handleRefreshLeases = () => {
getDhcpStatus();
};
return (
<>
<div class={s.addButton}>
<PlusButton
onClick={handleAddStaticLease}
disabled={!dhcpState.enabled || !dhcpState.v4?.range_start}
>
{intl.getMessage('dhcp_add_static_lease')}
</PlusButton>
</div>
<Show when={dhcpState.staticLeases && dhcpState.staticLeases.length > 0}>
<StaticLeasesTable
staticLeases={dhcpState.staticLeases || []}
processingDeleting={!!dhcpState.processingDeleting}
processingUpdating={!!dhcpState.processingUpdating}
onEdit={handleEditStaticLease}
onDelete={handleDeleteStaticLease}
onRefresh={handleRefreshLeases}
/>
</Show>
<Show when={confirmDeleteLease()}>
<ConfirmDialog
title={intl.getMessage('delete_confirm')}
text={intl.getMessage('delete_confirm_desc', {
ip: confirmDeleteLease()?.ip,
})}
buttonText={intl.getMessage('delete_table_action_confirm')}
cancelText={intl.getMessage('cancel')}
buttonVariant="danger"
onConfirm={handleConfirmDeleteLease}
onClose={() => setConfirmDeleteLease(null)}
/>
</Show>
</>
);
};

View file

@ -24,8 +24,6 @@ type Props = {
onRefresh: () => void;
};
const pageSize = 7;
export const StaticLeasesTable = (props: Props) => {
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
@ -110,7 +108,7 @@ export const StaticLeasesTable = (props: Props) => {
},
accessor: 'mac',
sortable: false,
fitContent: true,
width: 48,
render: (_value: unknown, row: StaticLease) => {
const rowId = `${row.mac}-${row.ip}`;
return (
@ -202,8 +200,9 @@ export const StaticLeasesTable = (props: Props) => {
>
<button
type="button"
class={s.actionButton}
class={cn(theme.table.action, s.actionButton)}
data-testid="static-lease-actions-dropdown"
data-table-action
>
<Icon icon="bullets" color="gray" />
</button>
@ -221,15 +220,6 @@ export const StaticLeasesTable = (props: Props) => {
data={props.staticLeases}
class={s.staticTable}
columns={columns()}
emptyTable={
<div class={s.emptyTableContent}>
<Icon icon="not_found_search" color="gray" class={s.emptyTableIcon} />
<div class={cn(theme.text.t3, s.emptyTableDesc)}>
{intl.getMessage('dhcp_static_leases_not_found')}
</div>
</div>
}
pageSize={pageSize}
getRowId={(row: StaticLease) => `${row.mac}-${row.ip}`}
/>
);

View file

@ -0,0 +1 @@
export { LeasesPage } from './LeasesPage';

View file

@ -0,0 +1,41 @@
.breadcrumbs,
.tabs {
padding: 24px 16px 0;
}
.tabContent {
padding: 16px 0 0 0;
}
.title {
padding: 0;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
}
.menuButton {
border: none;
background: transparent;
cursor: pointer;
color: var(--default-gray-icons);
padding: 4px;
border-radius: 4px;
&:hover {
color: var(--default-black-icons);
}
}
.addButton {
padding-bottom: 24px;
}

View file

@ -0,0 +1,57 @@
import type { Accessor } from 'solid-js';
import { SettingRow } from 'panel/common/ui/SettingRow';
import intl from 'panel/common/intl';
import { dhcpState, toggleDhcp } from 'panel/stores/dhcp';
type Props = {
selectedInterface: Accessor<string>;
onToggleOn?: () => void;
};
type DhcpToggleConfig = {
enabled: boolean;
interface_name: string;
v4?: typeof dhcpState.v4;
v6?: typeof dhcpState.v6;
};
export const DhcpToggle = (props: Props) => {
const disabled = () =>
dhcpState.processingDhcp ||
dhcpState.processingConfig ||
(!dhcpState.enabled && !props.selectedInterface());
const onChange = (checked: boolean) => {
if (checked) {
const v4 = dhcpState.v4;
const v6 = dhcpState.v6;
const hasV4Config = !!(v4 && Object.values(v4).some(Boolean));
const hasV6Config = !!(v6 && Object.values(v6).some(Boolean));
const values: DhcpToggleConfig = {
enabled: false,
interface_name: props.selectedInterface() || dhcpState.interface_name,
};
if (hasV4Config) values.v4 = v4;
if (hasV6Config) values.v6 = v6;
toggleDhcp(values);
if (!hasV4Config) {
props.onToggleOn?.();
}
} else {
toggleDhcp({ enabled: true });
}
};
return (
<SettingRow
id="dhcp_enabled"
variant="switch"
title={intl.getMessage('dhcp_enable')}
checked={!!dhcpState.enabled}
disabled={disabled()}
onChange={onChange}
/>
);
};

View file

@ -0,0 +1,20 @@
.formField {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
.formFieldLabel {
color: var(--default-labels);
}
.rangeRow {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
}

View file

@ -0,0 +1,215 @@
import { createSignal, createEffect, createMemo } from 'solid-js';
import type { Accessor } from 'solid-js';
import cn from 'clsx';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
import { Input } from 'panel/common/controls/Input';
import { dhcpState } from 'panel/stores/dhcp';
import {
validateIpv4,
validateIpv4RangeEnd,
validateNotInRange,
validateGatewaySubnetMask,
validateIpForGatewaySubnetMask,
} from 'panel/helpers/validators';
import s from './DhcpV4Modal.module.pcss';
export type V4Config = {
gateway_ip: string;
subnet_mask: string;
range_start: string;
range_end: string;
lease_duration: number;
};
type Props = {
open: boolean;
selectedInterface: Accessor<string>;
onClose: () => void;
onSave: (values: V4Config) => void;
};
export const DhcpV4Modal = (props: Props) => {
const [gatewayIp, setGatewayIp] = createSignal('');
const [subnetMask, setSubnetMask] = createSignal('');
const [rangeStart, setRangeStart] = createSignal('');
const [rangeEnd, setRangeEnd] = createSignal('');
const [leaseDuration, setLeaseDuration] = createSignal('');
const [gatewayIpError, setGatewayIpError] = createSignal('');
const [subnetMaskError, setSubnetMaskError] = createSignal('');
const [rangeStartError, setRangeStartError] = createSignal('');
const [rangeEndError, setRangeEndError] = createSignal('');
createEffect(() => {
if (props.open) {
const v4 = dhcpState.v4;
setGatewayIp(v4?.gateway_ip || '');
setSubnetMask(v4?.subnet_mask || '');
setRangeStart(v4?.range_start || '');
setRangeEnd(v4?.range_end || '');
setLeaseDuration(v4?.lease_duration ? String(v4.lease_duration) : '');
setGatewayIpError('');
setSubnetMaskError('');
setRangeStartError('');
setRangeEndError('');
}
});
const hasIpv4 = createMemo(
() =>
!!(
dhcpState.interfaces &&
dhcpState.interfaces[props.selectedInterface()]?.ipv4_addresses
),
);
const allValues = createMemo(() => ({
v4: {
gateway_ip: gatewayIp(),
subnet_mask: subnetMask(),
range_start: rangeStart(),
range_end: rangeEnd(),
},
}));
const isEmptyConfig = createMemo(
() => !gatewayIp() && !subnetMask() && !rangeStart() && !rangeEnd() && !leaseDuration(),
);
const validateGatewayIp = () => {
const err = validateIpv4(gatewayIp()) || validateNotInRange(gatewayIp(), allValues());
setGatewayIpError(err || '');
};
const validateSubnetMask = () => {
const err = validateGatewaySubnetMask(undefined, allValues());
setSubnetMaskError(err || '');
};
const validateRangeStart = () => {
const err =
validateIpv4(rangeStart()) || validateIpForGatewaySubnetMask(rangeStart(), allValues());
setRangeStartError(err || '');
};
const validateRangeEnd = () => {
const err =
validateIpv4(rangeEnd()) ||
validateIpv4RangeEnd(undefined, allValues()) ||
validateIpForGatewaySubnetMask(rangeEnd(), allValues());
setRangeEndError(err || '');
};
const onGatewayBlur = () => {
validateGatewayIp();
validateRangeStart();
validateRangeEnd();
};
const onRangeStartBlur = () => {
validateRangeStart();
validateGatewayIp();
validateRangeEnd();
};
const onRangeEndBlur = () => {
validateRangeEnd();
validateGatewayIp();
};
const onSubnetBlur = () => {
validateSubnetMask();
validateRangeStart();
validateRangeEnd();
};
const handleSave = () => {
validateGatewayIp();
validateSubnetMask();
validateRangeStart();
validateRangeEnd();
if (gatewayIpError() || subnetMaskError() || rangeStartError() || rangeEndError()) {
return;
}
props.onSave({
gateway_ip: gatewayIp().trim(),
subnet_mask: subnetMask().trim(),
range_start: rangeStart().trim(),
range_end: rangeEnd().trim(),
lease_duration: leaseDuration() ? Number(leaseDuration().trim()) : 0,
});
};
return (
<ConfigDialog
open={props.open}
title={intl.getMessage('dhcp_ipv4_settings')}
onClose={props.onClose}
onSubmit={handleSave}
processing={!!dhcpState.processingConfig}
submitDisabled={!hasIpv4() || isEmptyConfig()}
>
<div class={theme.form.input}>
<Input
value={gatewayIp()}
onChange={(e: Event) => setGatewayIp((e.target as HTMLInputElement).value)}
onBlur={onGatewayBlur}
id="v4_gateway_ip"
label={intl.getMessage('dhcp_form_gateway_address')}
placeholder="192.168.1.1"
disabled={!hasIpv4()}
errorMessage={gatewayIpError()}
size="large"
/>
</div>
<div class={s.formField}>
<span class={cn(theme.text.t3, s.formFieldLabel)}>
{intl.getMessage('dhcp_form_range_title')}
</span>
<div class={s.rangeRow}>
<Input
value={rangeStart()}
onChange={(e: Event) => setRangeStart((e.target as HTMLInputElement).value)}
onBlur={onRangeStartBlur}
id="v4_range_start"
placeholder="192.168.1.2"
disabled={!hasIpv4()}
errorMessage={rangeStartError()}
size="large"
/>
<Input
value={rangeEnd()}
onChange={(e: Event) => setRangeEnd((e.target as HTMLInputElement).value)}
onBlur={onRangeEndBlur}
id="v4_range_end"
placeholder="192.168.1.254"
disabled={!hasIpv4()}
errorMessage={rangeEndError()}
size="large"
/>
</div>
</div>
<div class={theme.form.input}>
<Input
value={subnetMask()}
onChange={(e: Event) => setSubnetMask((e.target as HTMLInputElement).value)}
onBlur={onSubnetBlur}
id="v4_subnet_mask"
label={intl.getMessage('dhcp_form_subnet_input')}
placeholder="255.255.255.0"
disabled={!hasIpv4()}
errorMessage={subnetMaskError()}
size="large"
/>
</div>
<div class={theme.form.input}>
<Input
value={leaseDuration()}
onChange={(e: Event) => setLeaseDuration((e.target as HTMLInputElement).value)}
id="v4_lease_duration"
inputMode="numeric"
label={intl.getMessage('dhcp_form_lease_title')}
placeholder="86400"
disabled={!hasIpv4()}
size="large"
/>
</div>
</ConfigDialog>
);
};

View file

@ -0,0 +1,2 @@
export { DhcpV4Modal } from './DhcpV4Modal';
export type { V4Config } from './DhcpV4Modal';

View file

@ -0,0 +1,96 @@
import { createSignal, createEffect, createMemo } from 'solid-js';
import type { Accessor } from 'solid-js';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
import { Input } from 'panel/common/controls/Input';
import { dhcpState } from 'panel/stores/dhcp';
import { validateIpv6 } from 'panel/helpers/validators';
export type V6Config = {
range_start: string;
lease_duration: number;
};
type Props = {
open: boolean;
selectedInterface: Accessor<string>;
onClose: () => void;
onSave: (values: V6Config) => void;
};
export const DhcpV6Modal = (props: Props) => {
const [rangeStart, setRangeStart] = createSignal('');
const [leaseDuration, setLeaseDuration] = createSignal('');
const [rangeStartError, setRangeStartError] = createSignal('');
createEffect(() => {
if (props.open) {
const v6 = dhcpState.v6;
setRangeStart(v6?.range_start || '');
setLeaseDuration(v6?.lease_duration ? String(v6.lease_duration) : '');
setRangeStartError('');
}
});
const hasIpv6 = createMemo(
() =>
!!(
dhcpState.interfaces &&
dhcpState.interfaces[props.selectedInterface()]?.ipv6_addresses
),
);
const isEmptyConfig = createMemo(() => !rangeStart() && !leaseDuration());
const validateRangeStart = () => {
const err = validateIpv6(rangeStart());
setRangeStartError(err || '');
};
const handleSave = () => {
validateRangeStart();
if (rangeStartError()) {
return;
}
props.onSave({
range_start: rangeStart().trim(),
lease_duration: leaseDuration() ? Number(leaseDuration()) : 0,
});
};
return (
<ConfigDialog
open={props.open}
title={intl.getMessage('dhcp_ipv6_settings')}
onClose={props.onClose}
onSubmit={handleSave}
processing={!!dhcpState.processingConfig}
submitDisabled={!hasIpv6() || isEmptyConfig()}
>
<div class={theme.form.input}>
<Input
id="v6_range_start"
label={intl.getMessage('dhcp_form_range_title')}
placeholder={intl.getMessage('dhcp_form_range_start')}
value={rangeStart()}
onChange={(e: Event) => setRangeStart((e.target as HTMLInputElement).value)}
onBlur={validateRangeStart}
errorMessage={rangeStartError()}
/>
</div>
<div class={theme.form.input}>
<Input
id="v6_lease_duration"
type="number"
label={intl.getMessage('dhcp_form_lease_title')}
placeholder="86400"
value={leaseDuration()}
onChange={(e: Event) => setLeaseDuration((e.target as HTMLInputElement).value)}
/>
</div>
</ConfigDialog>
);
};

View file

@ -1,62 +0,0 @@
import { Show, createMemo } from 'solid-js';
import cn from 'clsx';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { Select } from 'panel/common/controls/Select';
import { type DhcpInterfaces } from 'panel/initialState';
import s from '../Dhcp.module.pcss';
type InterfaceOption = {
value: string;
label: string;
};
type Props = {
interfaces?: DhcpInterfaces;
selectedInterface: string;
enabled: boolean;
onChange: (name: string) => void;
};
export const InterfaceSelect = (props: Props) => {
const options = createMemo<InterfaceOption[]>(() => {
if (!props.interfaces) return [];
return Object.keys(props.interfaces).map((key) => {
const iface = props.interfaces![key];
const name = iface.name || key;
const ipv4 = iface.ipv4_addresses?.[0] || '';
const ipv6 = iface.ipv6_addresses?.[0] || '';
const parts = [name, ipv4, ipv6].filter(Boolean);
return {
value: name,
label: parts.join(' — '),
};
});
});
const selected = createMemo(
() => options().find((opt) => opt.value === props.selectedInterface) || null,
);
return (
<Show when={props.interfaces}>
<div>
<span class={cn(theme.text.t3, s.fieldLabel)}>
{intl.getMessage('dhcp_interface_select')}
</span>
<Select
id="dhcp_interface"
options={options()}
value={selected()}
onChange={(option: any) => props.onChange(option.value)}
isDisabled={props.enabled}
placeholder={intl.getMessage('dhcp_interface_select')}
size="responsive"
height="big"
/>
</div>
</Show>
);
};

View file

@ -0,0 +1,40 @@
.section {
padding: 0 16px;
}
.selectWrap {
padding: 8px 0;
}
.label {
margin-bottom: 4px;
color: var(--default-labels);
}
.info {
padding: 8px 0;
}
.row {
font-size: var(--fs-text-t3);
line-height: var(--lh-t2-normal);
& strong {
font-weight: var(--weight-semi-bold);
}
}
.interfaceInfoMore {
color: var(--default-product-icon);
cursor: pointer;
margin-left: 4px;
}
.buttonWrap {
padding: 16px 0;
}
.button {
width: auto;
min-width: 232px;
}

View file

@ -0,0 +1,124 @@
import type { Accessor } from 'solid-js';
import cn from 'clsx';
import { Show } from 'solid-js';
import { Select } from 'panel/common/controls/Select';
import { Button } from 'panel/common/ui/Button';
import intl from 'panel/common/intl';
import { dhcpState, findActiveDhcp } from 'panel/stores/dhcp';
import s from './InterfaceSelector.module.pcss';
import theme from 'panel/lib/theme';
type Props = {
selectedInterface: Accessor<string>;
onInterfaceChange: (name: string) => void;
showAllIps: Accessor<boolean>;
onShowAllIps: () => void;
};
const MAX_VISIBLE_IPS = 2;
export const InterfaceSelector = (props: Props) => {
const interfaces = () => dhcpState.interfaces || {};
const selectOptions = () =>
Object.keys(interfaces()).map((name) => {
const iface = interfaces()[name];
const ipv4 = iface?.ipv4_addresses?.join(', ') || '';
const ipv6 = iface?.ipv6_addresses?.join(', ') || '';
let label = name;
if (ipv4) label += ` - ${ipv4}`;
if (ipv6) label += ` - ${ipv6}`;
return { label, value: name };
});
const selectedIface = () => interfaces()[props.selectedInterface()];
const gatewayIp = () => selectedIface()?.gateway_ip || '';
const hardwareAddress = () => selectedIface()?.hardware_address || '';
const ipAddresses = () => selectedIface()?.ip_addresses || [];
const displayIps = () => {
const ips = ipAddresses();
if (ips.length <= MAX_VISIBLE_IPS || props.showAllIps()) return ips;
return ips.slice(0, MAX_VISIBLE_IPS);
};
const remainingCount = () => {
const ips = ipAddresses();
if (ips.length <= MAX_VISIBLE_IPS || props.showAllIps()) return 0;
return ips.length - MAX_VISIBLE_IPS;
};
const handleCheck = () => {
if (props.selectedInterface()) {
findActiveDhcp(props.selectedInterface());
}
};
const selectedOption = () => selectOptions().find((o) => o.value === props.selectedInterface());
return (
<div class={s.section}>
<div class={s.selectWrap}>
<div class={cn(s.label, theme.text.t3)}>
{intl.getMessage('dhcp_interface_select')}
</div>
<Select
options={selectOptions()}
value={selectedOption()}
onChange={(option: { value: string }) => props.onInterfaceChange(option.value)}
size="responsive"
height="big"
/>
</div>
<Show when={props.selectedInterface()}>
<div class={s.info}>
<Show when={gatewayIp()}>
<div class={s.row}>
{intl.getMessage('dhcp_form_gateway_address_value', {
value: gatewayIp(),
})}
</div>
</Show>
<Show when={hardwareAddress()}>
<div class={s.row}>
{intl.getMessage('dhcp_hardware_address_value', {
value: hardwareAddress(),
})}
</div>
</Show>
<Show when={ipAddresses().length > 0}>
<div class={s.row}>
{intl.getMessage('dhcp_ip_addresses_value', {
value: displayIps().join(', '),
})}
<Show when={remainingCount() > 0}>
<button
type="button"
class={s.interfaceInfoMore}
onClick={() => props.onShowAllIps()}
>
{intl.getMessage('show_more_count', {
count: String(remainingCount()),
})}
</button>
</Show>
</div>
</Show>
</div>
<div class={s.buttonWrap}>
<Button
variant="primary"
size="small"
onClick={handleCheck}
class={s.button}
compact
>
{intl.getMessage('check_dhcp_servers')}
</Button>
</div>
</Show>
</div>
);
};

View file

@ -0,0 +1 @@
export { InterfaceSelector } from './InterfaceSelector';

View file

@ -1,235 +0,0 @@
import { createSignal, createEffect, createMemo } from 'solid-js';
import cn from 'clsx';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { Input } from 'panel/common/controls/Input';
import { Button } from 'panel/common/ui/Button';
import type { DhcpInterfaces } from 'panel/initialState';
import {
validateIpv4,
validateIpv4RangeEnd,
validateNotInRange,
validateGatewaySubnetMask,
validateIpForGatewaySubnetMask,
} from 'panel/helpers/validators';
import s from '../Dhcp.module.pcss';
type V4Config = {
gateway_ip: string;
subnet_mask: string;
range_start: string;
range_end: string;
lease_duration: number;
};
type Props = {
v4?: V4Config;
interfaces?: DhcpInterfaces;
selectedInterface: string;
processingConfig: boolean;
onSave: (values: V4Config) => void;
};
export const Ipv4Settings = (props: Props) => {
const [gatewayIp, setGatewayIp] = createSignal('');
const [subnetMask, setSubnetMask] = createSignal('');
const [rangeStart, setRangeStart] = createSignal('');
const [rangeEnd, setRangeEnd] = createSignal('');
const [leaseDuration, setLeaseDuration] = createSignal('');
const [gatewayIpError, setGatewayIpError] = createSignal('');
const [subnetMaskError, setSubnetMaskError] = createSignal('');
const [rangeStartError, setRangeStartError] = createSignal('');
const [rangeEndError, setRangeEndError] = createSignal('');
// Reset form when v4 changes
createEffect(() => {
const v4 = props.v4;
setGatewayIp(v4?.gateway_ip || '');
setSubnetMask(v4?.subnet_mask || '');
setRangeStart(v4?.range_start || '');
setRangeEnd(v4?.range_end || '');
setLeaseDuration(v4?.lease_duration ? String(v4.lease_duration) : '');
});
const hasIpv4 = createMemo(
() => !!(props.interfaces && props.interfaces[props.selectedInterface]?.ipv4_addresses),
);
const allValues = createMemo(() => ({
v4: {
gateway_ip: gatewayIp(),
subnet_mask: subnetMask(),
range_start: rangeStart(),
range_end: rangeEnd(),
lease_duration: leaseDuration(),
},
}));
const isEmptyConfig = createMemo(
() => !gatewayIp() && !subnetMask() && !rangeStart() && !rangeEnd() && !leaseDuration(),
);
const validateGatewayIp = () => {
const err = validateIpv4(gatewayIp()) || validateNotInRange(gatewayIp(), allValues());
setGatewayIpError(err || '');
};
const validateRangeStart = () => {
const err =
validateIpv4(rangeStart()) || validateIpForGatewaySubnetMask(rangeStart(), allValues());
setRangeStartError(err || '');
};
const validateRangeEnd = () => {
const err =
validateIpv4(rangeEnd()) ||
validateIpv4RangeEnd(undefined, allValues()) ||
validateIpForGatewaySubnetMask(rangeEnd(), allValues());
setRangeEndError(err || '');
};
const validateSubnetMask = () => {
const err = validateGatewaySubnetMask(undefined, allValues());
setSubnetMaskError(err || '');
};
const onFormSubmit = (e: Event) => {
e.preventDefault();
validateGatewayIp();
validateSubnetMask();
validateRangeStart();
validateRangeEnd();
if (gatewayIpError() || subnetMaskError() || rangeStartError() || rangeEndError()) {
return;
}
props.onSave({
gateway_ip: gatewayIp().trim(),
subnet_mask: subnetMask().trim(),
range_start: rangeStart().trim(),
range_end: rangeEnd().trim(),
lease_duration: leaseDuration() ? Number(leaseDuration().trim()) : 0,
});
};
return (
<form onSubmit={onFormSubmit} class={s.form}>
<div class={cn(theme.form.group, s.formGroup)}>
<div class={s.formField}>
<div>
<Input
value={gatewayIp()}
onChange={(e: Event) =>
setGatewayIp((e.target as HTMLInputElement).value)
}
onBlur={() => {
validateGatewayIp();
validateRangeStart();
validateRangeEnd();
}}
id="v4_gateway_ip"
label={intl.getMessage('dhcp_form_gateway_address')}
placeholder="192.168.1.1"
disabled={!hasIpv4()}
errorMessage={gatewayIpError()}
/>
</div>
</div>
<div class={s.formField}>
<span class={cn(theme.text.t3, s.formFieldLabel)}>
{intl.getMessage('dhcp_form_range_title')}
</span>
<div class={s.rangeRow}>
<div>
<Input
value={rangeStart()}
onChange={(e: Event) =>
setRangeStart((e.target as HTMLInputElement).value)
}
onBlur={() => {
validateRangeStart();
validateGatewayIp();
validateRangeEnd();
}}
id="v4_range_start"
placeholder="192.168.1.2"
disabled={!hasIpv4()}
errorMessage={rangeStartError()}
/>
</div>
<div>
<Input
value={rangeEnd()}
onChange={(e: Event) =>
setRangeEnd((e.target as HTMLInputElement).value)
}
onBlur={() => {
validateRangeEnd();
validateGatewayIp();
}}
id="v4_range_end"
placeholder="192.168.1.254"
disabled={!hasIpv4()}
errorMessage={rangeEndError()}
/>
</div>
</div>
</div>
<div class={s.formField}>
<div>
<Input
value={subnetMask()}
onChange={(e: Event) =>
setSubnetMask((e.target as HTMLInputElement).value)
}
onBlur={() => {
validateSubnetMask();
validateRangeStart();
validateRangeEnd();
}}
id="v4_subnet_mask"
label={intl.getMessage('dhcp_form_subnet_input')}
placeholder="255.255.255.0"
disabled={!hasIpv4()}
errorMessage={subnetMaskError()}
/>
</div>
</div>
<div class={s.formField}>
<div>
<Input
value={leaseDuration()}
onChange={(e: Event) =>
setLeaseDuration((e.target as HTMLInputElement).value)
}
id="v4_lease_duration"
inputMode="numeric"
label={intl.getMessage('dhcp_form_lease_title')}
placeholder="86400"
disabled={!hasIpv4()}
/>
</div>
</div>
</div>
<div class={theme.form.buttonGroup}>
<Button
type="submit"
variant="primary"
size="small"
disabled={props.processingConfig || !hasIpv4() || isEmptyConfig()}
class={theme.form.button}
>
{intl.getMessage('save')}
</Button>
</div>
</form>
);
};

View file

@ -1,113 +0,0 @@
import { createSignal, createEffect, createMemo } from 'solid-js';
import cn from 'clsx';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { Input } from 'panel/common/controls/Input';
import { Button } from 'panel/common/ui/Button';
import type { DhcpInterfaces } from 'panel/initialState';
import { validateIpv6 } from 'panel/helpers/validators';
import s from '../Dhcp.module.pcss';
type V6Config = {
range_start: string;
lease_duration: number;
};
type Props = {
v6?: V6Config;
interfaces?: DhcpInterfaces;
selectedInterface: string;
processingConfig: boolean;
onSave: (values: V6Config) => void;
};
export const Ipv6Settings = (props: Props) => {
const [rangeStart, setRangeStart] = createSignal('');
const [leaseDuration, setLeaseDuration] = createSignal('');
const [rangeStartError, setRangeStartError] = createSignal('');
// Sync with props
createEffect(() => {
setRangeStart(props.v6?.range_start || '');
setLeaseDuration(props.v6?.lease_duration ? String(props.v6.lease_duration) : '');
});
const hasIpv6 = createMemo(
() => !!(props.interfaces && props.interfaces[props.selectedInterface]?.ipv6_addresses),
);
const isEmptyConfig = createMemo(() => !rangeStart() && !leaseDuration());
const validateRangeStart = () => {
const err = validateIpv6(rangeStart());
setRangeStartError(err || '');
};
const handleSubmit = (e: Event) => {
e.preventDefault();
validateRangeStart();
if (rangeStartError()) {
return;
}
props.onSave({
range_start: rangeStart().trim(),
lease_duration: leaseDuration() ? Number(leaseDuration()) : 0,
});
};
return (
<form onSubmit={handleSubmit} class={s.form}>
<div class={cn(theme.form.group, s.formGroup)}>
<div class={s.formField}>
<span class={cn(theme.text.t3, s.formFieldLabel)}>
{intl.getMessage('dhcp_form_range_title')}
</span>
<div class={s.formField}>
<Input
id="v6_range_start"
placeholder={intl.getMessage('dhcp_form_range_start')}
value={rangeStart()}
onChange={(e: Event) =>
setRangeStart((e.target as HTMLInputElement).value)
}
onBlur={validateRangeStart}
errorMessage={rangeStartError()}
disabled={!hasIpv6()}
/>
</div>
</div>
<div class={s.formField}>
<Input
id="v6_lease_duration"
type="number"
label={intl.getMessage('dhcp_form_lease_title')}
placeholder="86400"
value={leaseDuration()}
onChange={(e: Event) =>
setLeaseDuration((e.target as HTMLInputElement).value)
}
disabled={!hasIpv6()}
/>
</div>
</div>
<div class={theme.form.buttonGroup}>
<Button
type="submit"
variant="primary"
size="small"
disabled={props.processingConfig || !hasIpv6() || isEmptyConfig()}
class={theme.form.button}
>
{intl.getMessage('save')}
</Button>
</div>
</form>
);
};

View file

@ -0,0 +1,20 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 16px;
}
.title {
padding: 0;
}
.unavailable {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
text-align: center;
gap: 12px;
}

View file

@ -9,5 +9,5 @@
.description {
font-size: var(--fs-text-t2);
line-height: var(--lh-text-t2);
line-height: var(--lh-t2-normal);
}

View file

@ -5,7 +5,7 @@
border: none;
background: transparent;
font-size: var(--fs-text-t2);
line-height: var(--lh-text-t2);
line-height: var(--lh-t2-normal);
}
.footer {

View file

@ -171,6 +171,7 @@ export const MENU_URLS = {
export const SETTINGS_URLS = {
encryption: '/encryption',
dhcp: '/dhcp',
dhcpLeases: '/dhcp/leases',
dns: '/dns',
settings: '/settings',
clients: '/clients',

View file

@ -21,6 +21,10 @@
max-width: 100%;
}
}
&_compact {
padding-top: 0;
}
}
.containerIn {

View file

@ -12,7 +12,7 @@
&:disabled,
&.disabled {
cursor: not-allowed;
color: var(--disabled-link);
color: var(--disabled-main-button);
outline: 0;
}

View file

@ -8,6 +8,8 @@ import { enrichWithConcatenatedIpAddresses } from 'panel/helpers/helpers';
type Lease = { hostname: string; ip: string; mac: string };
export type LeaseModalType = 'ADD_LEASE' | 'EDIT_LEASE' | 'MAKE_STATIC';
type DhcpState = {
processing: boolean;
processingStatus: boolean;
@ -36,7 +38,7 @@ type DhcpState = {
staticLeases: Lease[];
isModalOpen: boolean;
leaseModalConfig: Lease | undefined;
modalType: string;
modalType: LeaseModalType | '';
dhcp_available: boolean;
staticIpError: boolean;
interfaces?: Record<string, any>;
@ -220,9 +222,6 @@ export const toggleDhcp = async (config?: any) => {
const payload = { ...values, enabled };
await apiClient.setDhcpConfig(payload);
setState({ enabled, check: null, processingConfig: false });
addSuccessToast(
enabled ? intl.getMessage('enabled_dhcp') : intl.getMessage('disabled_dhcp'),
);
} catch (error) {
addErrorToast({ error });
setState('processingConfig', false);
@ -255,7 +254,7 @@ export const resetDhcpLeases = async () => {
}
};
export const toggleLeaseModal = (modalType?: string, leaseConfig?: Lease) => {
export const toggleLeaseModal = (modalType?: LeaseModalType, leaseConfig?: Lease) => {
setState({
isModalOpen: !state.isModalOpen,
modalType: modalType || '',