diff --git a/client_v3/src/__locales/en.json b/client_v3/src/__locales/en.json index b6fba443f..17dc6b759 100644 --- a/client_v3/src/__locales/en.json +++ b/client_v3/src/__locales/en.json @@ -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 %interfaceName%. In order to use DHCP server, a static IP address must be set. Your current IP address is %ipAddress%. 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": "Gateway IP address: %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": "Hardware address: %value%", + "dhcp_ip_addresses_value": "IP addresses: %value%", "dhcp_table_mac_address": "MAC address", "dhcp_table_ip_address": "IP address", "dhcp_table_hostname": "Hostname", diff --git a/client_v3/src/__tests__/dhcp-toggle.test.tsx b/client_v3/src/__tests__/dhcp-toggle.test.tsx new file mode 100644 index 000000000..01127ab62 --- /dev/null +++ b/client_v3/src/__tests__/dhcp-toggle.test.tsx @@ -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(() => '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(() => ( + '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(() => ( + '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(() => ''} />); + const input = container.querySelector('#dhcp_enabled') as HTMLInputElement; + expect(input.disabled).toBe(true); + }); +}); diff --git a/client_v3/src/__tests__/ipv4-settings.test.tsx b/client_v3/src/__tests__/ipv4-settings.test.tsx deleted file mode 100644 index f66a1e6fc..000000000 --- a/client_v3/src/__tests__/ipv4-settings.test.tsx +++ /dev/null @@ -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(() => ); - 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(() => ); - - 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(() => ); - - 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(() => ); - - 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(() => ); - - 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(() => ); - - 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(() => ); - - 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(); - }); - }); -}); diff --git a/client_v3/src/common/controls/Radio/Radio.module.pcss b/client_v3/src/common/controls/Radio/Radio.module.pcss index 925ff4522..05efc9a8c 100644 --- a/client_v3/src/common/controls/Radio/Radio.module.pcss +++ b/client_v3/src/common/controls/Radio/Radio.module.pcss @@ -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 { diff --git a/client_v3/src/common/ui/PlusButton/PlusButton.module.pcss b/client_v3/src/common/ui/PlusButton/PlusButton.module.pcss index efa47f4bb..6f0216099 100644 --- a/client_v3/src/common/ui/PlusButton/PlusButton.module.pcss +++ b/client_v3/src/common/ui/PlusButton/PlusButton.module.pcss @@ -21,7 +21,7 @@ } &:disabled { - color: var(--disabled-main-text); + color: var(--disabled-main-button); cursor: default; } diff --git a/client_v3/src/common/ui/Table/Table.tsx b/client_v3/src/common/ui/Table/Table.tsx index 6313e69f1..df040f329 100644 --- a/client_v3/src/common/ui/Table/Table.tsx +++ b/client_v3/src/common/ui/Table/Table.tsx @@ -34,7 +34,7 @@ export interface TableColumn { export interface TableProps { data: T[]; columns: TableColumn[]; - emptyTable: JSX.Element; + emptyTable?: JSX.Element; loading?: boolean; class?: string; pagination?: boolean; @@ -307,7 +307,7 @@ export const Table = >(props: TableProps) => { - +
{props.emptyTable}
diff --git a/client_v3/src/common/ui/Tabs/Tabs.module.pcss b/client_v3/src/common/ui/Tabs/Tabs.module.pcss index 3aeb69e8f..dd3504a34 100644 --- a/client_v3/src/common/ui/Tabs/Tabs.module.pcss +++ b/client_v3/src/common/ui/Tabs/Tabs.module.pcss @@ -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; diff --git a/client_v3/src/components/App/index.tsx b/client_v3/src/components/App/index.tsx index 564340dc6..10eade817 100644 --- a/client_v3/src/components/App/index.tsx +++ b/client_v3/src/components/App/index.tsx @@ -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 = () => { + diff --git a/client_v3/src/components/Dhcp/Dhcp.module.pcss b/client_v3/src/components/Dhcp/Dhcp.module.pcss deleted file mode 100644 index f924a6bdb..000000000 --- a/client_v3/src/components/Dhcp/Dhcp.module.pcss +++ /dev/null @@ -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; -} diff --git a/client_v3/src/components/Dhcp/Dhcp.tsx b/client_v3/src/components/Dhcp/Dhcp.tsx index 59546a354..cb52c3f55 100644 --- a/client_v3/src/components/Dhcp/Dhcp.tsx +++ b/client_v3/src/components/Dhcp/Dhcp.tsx @@ -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(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 ( - <> - - - + const resetMenu = ( +
+ {intl.getMessage('reset_dhcp_settings')} +
+ ); - -
-
+ const isLoaded = () => !dhcpState.processing && !dhcpState.processingInterfaces; + + return ( +
+
+ }> +

{intl.getMessage('unavailable_dhcp')}

{intl.getMessage('unavailable_dhcp_desc')}

-
-
- + - -
-
-

- {intl.getMessage('dhcp_settings')} -

- -
- -
- -
- - -
- -
- - {intl.getMessage('dhcp_form_gateway_input')}: - - - {selectedIface()?.gateway_ip} - -
-
- -
- - {intl.getMessage('dhcp_hardware_address')}: - - - {selectedIface()?.hardware_address} - -
-
- 0}> -
- - {intl.getMessage('dhcp_ip_addresses')}: - - - {visibleIps().join(', ')} - - 0}> - setShowAllIps(true)} - > - {intl.getMessage('show_more_count', { - count: hiddenIpsCount(), - })} - - -
-
-
-
- -
- - -
-
-
- - -
- {intl.getMessage('dhcp_warning')} -
-
- -
-

+
+

- {intl.getMessage('dhcp_ipv4_settings')} -

- + + + +
+ + + +
+ setShowAllIps(true)} />
-

- {intl.getMessage('dhcp_ipv6_settings')} -

- + hasIpv6() && v6Dialog.openDialog()} + /> + navigate(SETTINGS_URLS.dhcpLeases)} />
-
-

- {intl.getMessage('dhcp_static_leases')} -

-
- 0 - } - fallback={ -
- {intl.getMessage('static_dhcp_leases_not_found')} -
- } - > - -
-
-
- - -
-
+ -

- {intl.getMessage('dhcp_leases')} -

+ -
- 0} - fallback={ -
- {intl.getMessage('dynamic_dhcp_leases_not_found')} -
- } - > - -
-
- - - - - - + setConfirmResetSettings(false)} + submitDisabled={!!dhcpState.processingReset} + onConfirm={() => { + resetDhcp(); + getDhcpStatus(); + resetDialog.closeDialog(); + }} + onClose={resetDialog.closeDialog} /> - - - setConfirmResetLeases(false)} - /> - - - - setConfirmDeleteLease(null)} - /> - -

-
- - + + +
+
); }; diff --git a/client_v3/src/components/Dhcp/LeasesPage/DynamicLeasesTab.tsx b/client_v3/src/components/Dhcp/LeasesPage/DynamicLeasesTab.tsx new file mode 100644 index 000000000..3fb47ad35 --- /dev/null +++ b/client_v3/src/components/Dhcp/LeasesPage/DynamicLeasesTab.tsx @@ -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(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 ( + <> + 0}> + + + + + setConfirmDeleteLease(null)} + /> + + + ); +}; diff --git a/client_v3/src/components/Dhcp/blocks/DynamicLeasesTable.tsx b/client_v3/src/components/Dhcp/LeasesPage/DynamicLeasesTable.tsx similarity index 98% rename from client_v3/src/components/Dhcp/blocks/DynamicLeasesTable.tsx rename to client_v3/src/components/Dhcp/LeasesPage/DynamicLeasesTable.tsx index 118e2cc84..fabbd38e7 100644 --- a/client_v3/src/components/Dhcp/blocks/DynamicLeasesTable.tsx +++ b/client_v3/src/components/Dhcp/LeasesPage/DynamicLeasesTable.tsx @@ -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) => { > diff --git a/client_v3/src/components/Dhcp/LeasesPage/LeasesPage.tsx b/client_v3/src/components/Dhcp/LeasesPage/LeasesPage.tsx new file mode 100644 index 000000000..8c442672d --- /dev/null +++ b/client_v3/src/components/Dhcp/LeasesPage/LeasesPage.tsx @@ -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 = ( +
+ {intl.getMessage('dhcp_reset_leases')} +
+ ); + + return ( +
+
+
+ +
+ +
+

+ {intl.getMessage('dhcp_leases_title')} +

+
+ + + +
+
+ + , + }, + { + id: LEASE_TABS.DYNAMIC, + label: intl.getMessage('dhcp_leases'), + content: , + }, + ]} + /> + + + + + + + setConfirmResetLeases(false)} + /> + +
+
+ ); +}; diff --git a/client_v3/src/components/Dhcp/blocks/LeasesTable.module.pcss b/client_v3/src/components/Dhcp/LeasesPage/LeasesTable.module.pcss similarity index 100% rename from client_v3/src/components/Dhcp/blocks/LeasesTable.module.pcss rename to client_v3/src/components/Dhcp/LeasesPage/LeasesTable.module.pcss diff --git a/client_v3/src/components/Dhcp/blocks/StaticLeaseModal.tsx b/client_v3/src/components/Dhcp/LeasesPage/StaticLeaseModal.tsx similarity index 93% rename from client_v3/src/components/Dhcp/blocks/StaticLeaseModal.tsx rename to client_v3/src/components/Dhcp/LeasesPage/StaticLeaseModal.tsx index 8e18ee267..c5d35b9b8 100644 --- a/client_v3/src/components/Dhcp/blocks/StaticLeaseModal.tsx +++ b/client_v3/src/components/Dhcp/LeasesPage/StaticLeaseModal.tsx @@ -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" />
@@ -167,6 +170,7 @@ export const StaticLeaseModal = (props: Props) => { placeholder={intl.getMessage('form_enter_hostname')} errorMessage={hostnameError()} disabled={props.isMakeStatic} + size="large" /> @@ -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" /> diff --git a/client_v3/src/components/Dhcp/LeasesPage/StaticLeasesTab.tsx b/client_v3/src/components/Dhcp/LeasesPage/StaticLeasesTab.tsx new file mode 100644 index 000000000..6e447232f --- /dev/null +++ b/client_v3/src/components/Dhcp/LeasesPage/StaticLeasesTab.tsx @@ -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(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 ( + <> +
+ + {intl.getMessage('dhcp_add_static_lease')} + +
+ + 0}> + + + + + setConfirmDeleteLease(null)} + /> + + + ); +}; diff --git a/client_v3/src/components/Dhcp/blocks/StaticLeasesTable.tsx b/client_v3/src/components/Dhcp/LeasesPage/StaticLeasesTable.tsx similarity index 94% rename from client_v3/src/components/Dhcp/blocks/StaticLeasesTable.tsx rename to client_v3/src/components/Dhcp/LeasesPage/StaticLeasesTable.tsx index 8720f5965..3d5839411 100644 --- a/client_v3/src/components/Dhcp/blocks/StaticLeasesTable.tsx +++ b/client_v3/src/components/Dhcp/LeasesPage/StaticLeasesTable.tsx @@ -24,8 +24,6 @@ type Props = { onRefresh: () => void; }; -const pageSize = 7; - export const StaticLeasesTable = (props: Props) => { const [openMenuId, setOpenMenuId] = createSignal(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) => { > @@ -221,15 +220,6 @@ export const StaticLeasesTable = (props: Props) => { data={props.staticLeases} class={s.staticTable} columns={columns()} - emptyTable={ -
- -
- {intl.getMessage('dhcp_static_leases_not_found')} -
-
- } - pageSize={pageSize} getRowId={(row: StaticLease) => `${row.mac}-${row.ip}`} /> ); diff --git a/client_v3/src/components/Dhcp/LeasesPage/index.ts b/client_v3/src/components/Dhcp/LeasesPage/index.ts new file mode 100644 index 000000000..7cd1bd477 --- /dev/null +++ b/client_v3/src/components/Dhcp/LeasesPage/index.ts @@ -0,0 +1 @@ +export { LeasesPage } from './LeasesPage'; diff --git a/client_v3/src/components/Dhcp/LeasesPage/styles.module.pcss b/client_v3/src/components/Dhcp/LeasesPage/styles.module.pcss new file mode 100644 index 000000000..faa82ad01 --- /dev/null +++ b/client_v3/src/components/Dhcp/LeasesPage/styles.module.pcss @@ -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; +} diff --git a/client_v3/src/components/Dhcp/blocks/DhcpToggle.tsx b/client_v3/src/components/Dhcp/blocks/DhcpToggle.tsx new file mode 100644 index 000000000..fd969606d --- /dev/null +++ b/client_v3/src/components/Dhcp/blocks/DhcpToggle.tsx @@ -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; + 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 ( + + ); +}; diff --git a/client_v3/src/components/Dhcp/blocks/DhcpV4Modal/DhcpV4Modal.module.pcss b/client_v3/src/components/Dhcp/blocks/DhcpV4Modal/DhcpV4Modal.module.pcss new file mode 100644 index 000000000..2dbbe9a93 --- /dev/null +++ b/client_v3/src/components/Dhcp/blocks/DhcpV4Modal/DhcpV4Modal.module.pcss @@ -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; + } +} diff --git a/client_v3/src/components/Dhcp/blocks/DhcpV4Modal/DhcpV4Modal.tsx b/client_v3/src/components/Dhcp/blocks/DhcpV4Modal/DhcpV4Modal.tsx new file mode 100644 index 000000000..8fbce397c --- /dev/null +++ b/client_v3/src/components/Dhcp/blocks/DhcpV4Modal/DhcpV4Modal.tsx @@ -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; + 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 ( + +
+ 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" + /> +
+
+ + {intl.getMessage('dhcp_form_range_title')} + +
+ setRangeStart((e.target as HTMLInputElement).value)} + onBlur={onRangeStartBlur} + id="v4_range_start" + placeholder="192.168.1.2" + disabled={!hasIpv4()} + errorMessage={rangeStartError()} + size="large" + /> + setRangeEnd((e.target as HTMLInputElement).value)} + onBlur={onRangeEndBlur} + id="v4_range_end" + placeholder="192.168.1.254" + disabled={!hasIpv4()} + errorMessage={rangeEndError()} + size="large" + /> +
+
+
+ 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" + /> +
+
+ 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" + /> +
+
+ ); +}; diff --git a/client_v3/src/components/Dhcp/blocks/DhcpV4Modal/index.ts b/client_v3/src/components/Dhcp/blocks/DhcpV4Modal/index.ts new file mode 100644 index 000000000..912ad61fd --- /dev/null +++ b/client_v3/src/components/Dhcp/blocks/DhcpV4Modal/index.ts @@ -0,0 +1,2 @@ +export { DhcpV4Modal } from './DhcpV4Modal'; +export type { V4Config } from './DhcpV4Modal'; diff --git a/client_v3/src/components/Dhcp/blocks/DhcpV6Modal.tsx b/client_v3/src/components/Dhcp/blocks/DhcpV6Modal.tsx new file mode 100644 index 000000000..95e6492ef --- /dev/null +++ b/client_v3/src/components/Dhcp/blocks/DhcpV6Modal.tsx @@ -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; + 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 ( + +
+ setRangeStart((e.target as HTMLInputElement).value)} + onBlur={validateRangeStart} + errorMessage={rangeStartError()} + /> +
+ +
+ setLeaseDuration((e.target as HTMLInputElement).value)} + /> +
+
+ ); +}; diff --git a/client_v3/src/components/Dhcp/blocks/InterfaceSelect.tsx b/client_v3/src/components/Dhcp/blocks/InterfaceSelect.tsx deleted file mode 100644 index 74a2b0bfd..000000000 --- a/client_v3/src/components/Dhcp/blocks/InterfaceSelect.tsx +++ /dev/null @@ -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(() => { - 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 ( - -
- - {intl.getMessage('dhcp_interface_select')} - - props.onInterfaceChange(option.value)} + size="responsive" + height="big" + /> +
+ +
+ +
+ {intl.getMessage('dhcp_form_gateway_address_value', { + value: gatewayIp(), + })} +
+
+ +
+ {intl.getMessage('dhcp_hardware_address_value', { + value: hardwareAddress(), + })} +
+
+ 0}> +
+ {intl.getMessage('dhcp_ip_addresses_value', { + value: displayIps().join(', '), + })} + + 0}> + + +
+
+
+
+ +
+
+ + ); +}; diff --git a/client_v3/src/components/Dhcp/blocks/InterfaceSelector/index.ts b/client_v3/src/components/Dhcp/blocks/InterfaceSelector/index.ts new file mode 100644 index 000000000..bcf8c4698 --- /dev/null +++ b/client_v3/src/components/Dhcp/blocks/InterfaceSelector/index.ts @@ -0,0 +1 @@ +export { InterfaceSelector } from './InterfaceSelector'; diff --git a/client_v3/src/components/Dhcp/blocks/Ipv4Settings.tsx b/client_v3/src/components/Dhcp/blocks/Ipv4Settings.tsx deleted file mode 100644 index bbf8cfb5f..000000000 --- a/client_v3/src/components/Dhcp/blocks/Ipv4Settings.tsx +++ /dev/null @@ -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 ( -
-
-
-
- - 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()} - /> -
-
- -
- - {intl.getMessage('dhcp_form_range_title')} - -
-
- - setRangeStart((e.target as HTMLInputElement).value) - } - onBlur={() => { - validateRangeStart(); - validateGatewayIp(); - validateRangeEnd(); - }} - id="v4_range_start" - placeholder="192.168.1.2" - disabled={!hasIpv4()} - errorMessage={rangeStartError()} - /> -
-
- - setRangeEnd((e.target as HTMLInputElement).value) - } - onBlur={() => { - validateRangeEnd(); - validateGatewayIp(); - }} - id="v4_range_end" - placeholder="192.168.1.254" - disabled={!hasIpv4()} - errorMessage={rangeEndError()} - /> -
-
-
- -
-
- - 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()} - /> -
-
- -
-
- - setLeaseDuration((e.target as HTMLInputElement).value) - } - id="v4_lease_duration" - inputMode="numeric" - label={intl.getMessage('dhcp_form_lease_title')} - placeholder="86400" - disabled={!hasIpv4()} - /> -
-
-
- -
- -
-
- ); -}; diff --git a/client_v3/src/components/Dhcp/blocks/Ipv6Settings.tsx b/client_v3/src/components/Dhcp/blocks/Ipv6Settings.tsx deleted file mode 100644 index 6fc24fb6f..000000000 --- a/client_v3/src/components/Dhcp/blocks/Ipv6Settings.tsx +++ /dev/null @@ -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 ( -
-
-
- - {intl.getMessage('dhcp_form_range_title')} - -
- - setRangeStart((e.target as HTMLInputElement).value) - } - onBlur={validateRangeStart} - errorMessage={rangeStartError()} - disabled={!hasIpv6()} - /> -
-
- -
- - setLeaseDuration((e.target as HTMLInputElement).value) - } - disabled={!hasIpv6()} - /> -
-
- -
- -
-
- ); -}; diff --git a/client_v3/src/components/Dhcp/styles.module.pcss b/client_v3/src/components/Dhcp/styles.module.pcss new file mode 100644 index 000000000..2125a6b2f --- /dev/null +++ b/client_v3/src/components/Dhcp/styles.module.pcss @@ -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; +} diff --git a/client_v3/src/components/DnsSettings/Cache/Cache.module.pcss b/client_v3/src/components/DnsSettings/Cache/Cache.module.pcss index f53a059d5..622a66547 100644 --- a/client_v3/src/components/DnsSettings/Cache/Cache.module.pcss +++ b/client_v3/src/components/DnsSettings/Cache/Cache.module.pcss @@ -9,5 +9,5 @@ .description { font-size: var(--fs-text-t2); - line-height: var(--lh-text-t2); + line-height: var(--lh-t2-normal); } diff --git a/client_v3/src/components/Encryption/blocks/AddTlsCert/styles.module.pcss b/client_v3/src/components/Encryption/blocks/AddTlsCert/styles.module.pcss index 0ca93fa5f..99fa31e5a 100644 --- a/client_v3/src/components/Encryption/blocks/AddTlsCert/styles.module.pcss +++ b/client_v3/src/components/Encryption/blocks/AddTlsCert/styles.module.pcss @@ -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 { diff --git a/client_v3/src/helpers/constants.ts b/client_v3/src/helpers/constants.ts index 00cbbe351..f84cfff95 100644 --- a/client_v3/src/helpers/constants.ts +++ b/client_v3/src/helpers/constants.ts @@ -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', diff --git a/client_v3/src/lib/theme/Layout.module.pcss b/client_v3/src/lib/theme/Layout.module.pcss index 492a5a049..1190a46a6 100644 --- a/client_v3/src/lib/theme/Layout.module.pcss +++ b/client_v3/src/lib/theme/Layout.module.pcss @@ -21,6 +21,10 @@ max-width: 100%; } } + + &_compact { + padding-top: 0; + } } .containerIn { diff --git a/client_v3/src/lib/theme/Link.module.pcss b/client_v3/src/lib/theme/Link.module.pcss index d1cce5d7d..61b856450 100644 --- a/client_v3/src/lib/theme/Link.module.pcss +++ b/client_v3/src/lib/theme/Link.module.pcss @@ -12,7 +12,7 @@ &:disabled, &.disabled { cursor: not-allowed; - color: var(--disabled-link); + color: var(--disabled-main-button); outline: 0; } diff --git a/client_v3/src/stores/dhcp.ts b/client_v3/src/stores/dhcp.ts index 331b02d16..56ae81e5d 100644 --- a/client_v3/src/stores/dhcp.ts +++ b/client_v3/src/stores/dhcp.ts @@ -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; @@ -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 || '',