mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2026-08-04 15:28:58 +00:00
AGDNS-4175 update DHCP page
This commit is contained in:
parent
4bab2a0b7b
commit
2150e16efc
37 changed files with 1250 additions and 1265 deletions
|
|
@ -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 can’t 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",
|
||||
|
|
|
|||
87
client_v3/src/__tests__/dhcp-toggle.test.tsx
Normal file
87
client_v3/src/__tests__/dhcp-toggle.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--disabled-main-text);
|
||||
color: var(--disabled-main-button);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
186
client_v3/src/components/Dhcp/LeasesPage/LeasesPage.tsx
Normal file
186
client_v3/src/components/Dhcp/LeasesPage/LeasesPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
81
client_v3/src/components/Dhcp/LeasesPage/StaticLeasesTab.tsx
Normal file
81
client_v3/src/components/Dhcp/LeasesPage/StaticLeasesTab.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
1
client_v3/src/components/Dhcp/LeasesPage/index.ts
Normal file
1
client_v3/src/components/Dhcp/LeasesPage/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { LeasesPage } from './LeasesPage';
|
||||
41
client_v3/src/components/Dhcp/LeasesPage/styles.module.pcss
Normal file
41
client_v3/src/components/Dhcp/LeasesPage/styles.module.pcss
Normal 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;
|
||||
}
|
||||
57
client_v3/src/components/Dhcp/blocks/DhcpToggle.tsx
Normal file
57
client_v3/src/components/Dhcp/blocks/DhcpToggle.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
215
client_v3/src/components/Dhcp/blocks/DhcpV4Modal/DhcpV4Modal.tsx
Normal file
215
client_v3/src/components/Dhcp/blocks/DhcpV4Modal/DhcpV4Modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { DhcpV4Modal } from './DhcpV4Modal';
|
||||
export type { V4Config } from './DhcpV4Modal';
|
||||
96
client_v3/src/components/Dhcp/blocks/DhcpV6Modal.tsx
Normal file
96
client_v3/src/components/Dhcp/blocks/DhcpV6Modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { InterfaceSelector } from './InterfaceSelector';
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
20
client_v3/src/components/Dhcp/styles.module.pcss
Normal file
20
client_v3/src/components/Dhcp/styles.module.pcss
Normal 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;
|
||||
}
|
||||
|
|
@ -9,5 +9,5 @@
|
|||
|
||||
.description {
|
||||
font-size: var(--fs-text-t2);
|
||||
line-height: var(--lh-text-t2);
|
||||
line-height: var(--lh-t2-normal);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@
|
|||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&_compact {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.containerIn {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
&:disabled,
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--disabled-link);
|
||||
color: var(--disabled-main-button);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue