AGH-246 Fix table, cache, and logout bugs

This commit is contained in:
Ildar Kamalov authored and GitHub committed 2026-10-20 15:46:03 +03:00
1 parent fb7774fa5c
commit b13042b071
11 files changed
+1175 -27

No files matched your search

+2 -1
View File
@@ -1,7 +1,7 @@
# This comment is used to simplify checking local copies of the file. Bump
# this number every time a significant change is made to this file.
#
# AdGuard-Project-Version: 1
# AdGuard-Project-Version: 2
# Please, DO NOT put your text editors' temporary files here. The more are
# added, the harder it gets to maintain and manage projects' gitignores. Put
@@ -23,6 +23,7 @@
/client/playwright-report/
/client/playwright/.cache/
/client/test-results/
/client_v2/.env.local
/client_v2/blob-report/
/client_v2/playwright-report/
/client_v2/playwright/.cache/
+8
View File
@@ -54,6 +54,12 @@ NOTE: Add new changes BELOW THIS COMMENT.
- Passwords containing emoji being rejected by the first-run setup wizard in the new web interface.
- Cache size and TTL override rows being locked while the DNS cache was disabled in the new web interface ([#8644]).
- Tables staying on an empty page after the last row of that page was deleted in the new web interface ([#8644]).
- The *Log out* button opening the login page in a new tab in the new web interface ([#8661]).
### Removed
- The support for macOS 13 Ventura, see [Go 1.27 release notes][go-1.27-notes].
@@ -66,6 +72,8 @@ NOTE: Add new changes BELOW THIS COMMENT.
[#8613]: https://github.com/AdguardTeam/AdGuardHome/issues/8613
[#8636]: https://github.com/AdguardTeam/AdGuardHome/issues/8636
[#8637]: https://github.com/AdguardTeam/AdGuardHome/issues/8637
[#8644]: https://github.com/AdguardTeam/AdGuardHome/issues/8644
[#8661]: https://github.com/AdguardTeam/AdGuardHome/issues/8661
[go-1.27.2]: https://groups.google.com/g/golang-announce/c/U2fTuyDJznI
[go-1.27-notes]: https://go.dev/doc/go1.27#darwin
@@ -503,6 +503,18 @@ describe('numeric range validators', () => {
expect(validateBetween(16, 0, 32)).toBeUndefined();
});
it('validateBetween returns error for non-finite values', () => {
expect(validateBetween(NaN, 0, 32)).toBeTruthy();
expect(validateBetween(Infinity, 0, 32)).toBeTruthy();
expect(validateBetween(-Infinity, 0, 32)).toBeTruthy();
});
it('validateBetween optionally rejects non-integer values', () => {
expect(validateBetween(0.5, 0, 32, { integer: true })).toBeTruthy();
expect(validateBetween(0.5, 0, 32)).toBeUndefined();
expect(validateBetween(16, 0, 32, { integer: true })).toBeUndefined();
});
it('validateMinValue returns error when value is below min', () => {
expect(validateMinValue(0, 1)).toBeTruthy();
expect(validateMinValue(1, 1)).toBeUndefined();
@@ -539,6 +551,10 @@ describe('validateCacheSize', () => {
expect(result).toBeTruthy();
});
it('returns error for a fractional size when enabled', () => {
expect(validateCacheSize(1.5, true)).toBeTruthy();
});
it('returns undefined at UINT32_MAX boundary', () => {
expect(validateCacheSize(4294967295, true)).toBeUndefined();
});
@@ -0,0 +1,35 @@
import { render } from '@solidjs/testing-library';
import { MemoryRouter, Route, createMemoryHistory } from '@solidjs/router';
import { describe, expect, it, vi } from 'vitest';
vi.mock('panel/common/intl', async () =>
(await import('panel/__tests__/helpers/copy')).createIntlMock(),
);
import { Menu } from 'panel/common/ui/Menu';
const renderMenu = () => {
const history = createMemoryHistory();
history.set({ value: '/' });
return render(() => (
<MemoryRouter history={history}>
<Route
path="/*"
component={() => <Menu accountSubMenu={false} setAccountSubMenu={vi.fn()} />}
/>
</MemoryRouter>
));
};
describe('Menu', () => {
it('logs out in the current tab', () => {
const { container } = renderMenu();
const logoutLink = container.querySelector('#sign_out');
expect(logoutLink).not.toBeNull();
expect(logoutLink).toHaveAttribute('href', 'control/logout');
expect(logoutLink).not.toHaveAttribute('target');
expect(logoutLink).toHaveAttribute('rel', 'external');
});
});
@@ -0,0 +1,505 @@
import { createSignal, For } from 'solid-js';
import { render, screen, fireEvent } from '@solidjs/testing-library';
import { MemoryRouter, Route, createMemoryHistory } from '@solidjs/router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Table, type TableColumn } from 'panel/common/ui/Table';
type Row = {
name: string | null;
age: number;
city?: string;
};
const mocks = vi.hoisted(() => ({
onSortChange: vi.fn(),
onPageSizeChange: vi.fn(),
onRowClick: vi.fn(),
}));
vi.mock('panel/common/intl', async () =>
(await import('panel/__tests__/helpers/copy')).createIntlMock(),
);
vi.mock('panel/common/controls/Select', () => ({
Select: (props: any) => (
<select
data-testid="page-size-select"
value={props.value?.value}
onChange={(e: Event) => {
const select = e.currentTarget as HTMLSelectElement;
const option = props.options.find(
(item: any) => String(item.value) === select.value,
);
props.onChange(option);
}}
>
<For each={props.options}>
{(option: any) => <option value={option.value}>{option.label}</option>}
</For>
</select>
),
}));
const columns: TableColumn<Row>[] = [
{
key: 'name',
header: { text: 'Name header', class: 'name-header' },
accessor: 'name',
sortable: true,
class: 'name-column-cell',
render: (value: string | null) => (
<span data-testid="name-cell">{String(value ?? '')}</span>
),
},
{
key: 'age',
header: { text: 'Age header', class: 'age-header' },
accessor: 'age',
sortable: true,
render: (value: number) => <span data-testid="age-cell">{value}</span>,
},
{
key: 'city',
header: { text: 'City header', class: 'city-header' },
accessor: 'city',
sortable: false,
render: (value: string | undefined) => <span data-testid="city-cell">{value ?? ''}</span>,
},
];
const makeRows = (count: number): Row[] =>
Array.from({ length: count }, (_, index) => ({
name: `row-${String(index).padStart(2, '0')}`,
age: index,
city: 'City',
}));
const getNames = () => screen.getAllByTestId('name-cell').map((cell) => cell.textContent);
const renderTable = (options: {
data: Row[];
columns?: TableColumn<Row>[];
[key: string]: any;
}) => {
const { data, columns: tableColumns = columns, ...rest } = options;
return render(() => <Table<Row> data={data} columns={tableColumns} {...rest} />);
};
const getHeader = (container: HTMLElement, selector: string) => {
const header = container.querySelector(selector);
if (!(header instanceof HTMLElement)) {
throw new Error(`Table header not found: ${selector}`);
}
return header;
};
describe('Table', () => {
beforeEach(() => vi.clearAllMocks());
describe('rendering', () => {
it('renders headers and values from accessors', () => {
renderTable({ data: [{ name: 'Alice', age: 30, city: 'Paris' }] });
expect(screen.getByText('Name header')).toBeDefined();
expect(screen.getByText('Age header')).toBeDefined();
expect(screen.getByText('City header')).toBeDefined();
expect(screen.getByText('Alice')).toBeDefined();
expect(screen.getByText('30')).toBeDefined();
expect(screen.getByText('Paris')).toBeDefined();
});
it('renders custom cell content with value, row and index', () => {
const renderCell = vi.fn((value: string, row: Row, index: number) => (
<span data-testid="custom-cell">{`${index}:${row.name}:${value}`}</span>
));
renderTable({
data: [
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 40 },
],
columns: [
{
key: 'name',
header: { text: 'Name' },
accessor: 'name',
render: renderCell,
},
],
});
expect(renderCell).toHaveBeenNthCalledWith(1, 'Alice', { name: 'Alice', age: 30 }, 0);
expect(renderCell).toHaveBeenNthCalledWith(2, 'Bob', { name: 'Bob', age: 40 }, 1);
expect(screen.getByText('0:Alice:Alice')).toBeDefined();
expect(screen.getByText('1:Bob:Bob')).toBeDefined();
});
it('renders values from function accessors', () => {
renderTable({
data: [{ name: 'Alice', age: 30 }],
columns: [
{
key: 'name',
header: { text: 'Name' },
accessor: (row) => row.name?.toUpperCase(),
},
],
});
expect(screen.getByText('ALICE')).toBeDefined();
});
it('renders an empty cell when the accessor value is missing', () => {
const { container } = renderTable({
data: [{ name: 'Alice', age: 30 }],
columns: [{ key: 'city', header: { text: 'City' }, accessor: 'city' }],
tableRowClass: 'test-row',
});
expect(container.querySelector('.test-row')?.textContent).toBe('');
});
it('renders a custom column header', () => {
renderTable({
data: [{ name: 'Alice', age: 30 }],
columns: [
{
key: 'name',
header: {
text: 'Name',
render: () => <span data-testid="custom-header">Custom name</span>,
},
accessor: 'name',
},
],
});
expect(screen.getByTestId('custom-header')).toBeDefined();
});
});
describe('empty state', () => {
it('shows the empty state when there are no rows', () => {
renderTable({ data: [], emptyTable: <div>Nothing here</div> });
expect(screen.getByText('Nothing here')).toBeDefined();
});
it('does not show the empty state when rows exist', () => {
renderTable({
data: [{ name: 'Alice', age: 30 }],
emptyTable: <div>Nothing here</div>,
});
expect(screen.queryByText('Nothing here')).toBeNull();
});
});
describe('loading', () => {
it('shows a loader instead of the table while loading', () => {
const { container } = renderTable({
data: [{ name: 'Alice', age: 30 }],
loading: true,
});
expect(screen.queryByText('Name header')).toBeNull();
expect(screen.queryByText('Alice')).toBeNull();
expect(container.querySelector('svg')).not.toBeNull();
});
});
describe('sorting', () => {
const unsortedData: Row[] = [
{ name: 'Charlie', age: 3 },
{ name: 'Alice', age: 1 },
{ name: 'Bob', age: 2 },
];
it('applies the default sort on mount', () => {
renderTable({
data: unsortedData,
defaultSort: { key: 'name', direction: 'asc' },
});
expect(getNames()).toEqual(['Alice', 'Bob', 'Charlie']);
});
it('applies a descending default sort on mount', () => {
renderTable({
data: unsortedData,
defaultSort: { key: 'age', direction: 'desc' },
});
expect(getNames()).toEqual(['Charlie', 'Bob', 'Alice']);
});
it('toggles between ascending and descending when a header is clicked', () => {
const { container } = renderTable({ data: unsortedData });
fireEvent.click(getHeader(container, '.name-header'));
expect(getNames()).toEqual(['Alice', 'Bob', 'Charlie']);
fireEvent.click(getHeader(container, '.name-header'));
expect(getNames()).toEqual(['Charlie', 'Bob', 'Alice']);
});
it('sorts strings case-insensitively', () => {
const { container } = renderTable({
data: [
{ name: 'beta', age: 2 },
{ name: 'Alpha', age: 1 },
{ name: 'gamma', age: 3 },
],
});
fireEvent.click(getHeader(container, '.name-header'));
expect(getNames()).toEqual(['Alpha', 'beta', 'gamma']);
});
it('keeps null values last in both directions', () => {
const { container } = renderTable({
data: [
{ name: 'b', age: 2 },
{ name: null, age: 3 },
{ name: 'a', age: 1 },
],
});
fireEvent.click(getHeader(container, '.name-header'));
expect(getNames()).toEqual(['a', 'b', '']);
fireEvent.click(getHeader(container, '.name-header'));
expect(getNames()).toEqual(['b', 'a', '']);
});
it('does not sort a column marked as not sortable', () => {
const { container } = renderTable({ data: unsortedData });
fireEvent.click(getHeader(container, '.city-header'));
expect(getNames()).toEqual(['Charlie', 'Alice', 'Bob']);
});
it('does not sort when the table sortable prop is false', () => {
const { container } = renderTable({ data: unsortedData, sortable: false });
fireEvent.click(getHeader(container, '.name-header'));
expect(getNames()).toEqual(['Charlie', 'Alice', 'Bob']);
});
it('uses a custom sortFn', () => {
const { container } = renderTable({
data: [
{ name: 'ccc', age: 3 },
{ name: 'a', age: 1 },
{ name: 'bb', age: 2 },
],
columns: [
{
key: 'name',
header: { text: 'Name header', class: 'name-header' },
accessor: 'name',
sortable: true,
sortFn: (a: string, b: string) => a.length - b.length,
render: (value: string) => <span data-testid="name-cell">{value}</span>,
},
],
});
fireEvent.click(getHeader(container, '.name-header'));
expect(getNames()).toEqual(['a', 'bb', 'ccc']);
});
it('calls onSortChange with the column key and direction', () => {
const { container } = renderTable({
data: unsortedData,
onSortChange: mocks.onSortChange,
});
fireEvent.click(getHeader(container, '.name-header'));
expect(mocks.onSortChange).toHaveBeenLastCalledWith('name', 'asc');
fireEvent.click(getHeader(container, '.name-header'));
expect(mocks.onSortChange).toHaveBeenLastCalledWith('name', 'desc');
});
it('returns to the first page when sorting', () => {
const { container } = renderTable({ data: makeRows(15) });
fireEvent.click(screen.getByRole('button', { name: '2' }));
expect(getNames()[0]).toBe('row-10');
fireEvent.click(getHeader(container, '.name-header'));
expect(getNames()[0]).toBe('row-00');
expect(screen.queryByText('row-10')).toBeNull();
});
});
describe('pagination', () => {
it('paginates rows using the default page size', () => {
renderTable({ data: makeRows(11) });
expect(getNames()).toHaveLength(10);
expect(screen.queryByText('row-10')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '2' }));
expect(getNames()).toEqual(['row-10']);
});
it('uses the pageSize prop', () => {
renderTable({ data: makeRows(12), pageSize: 5 });
expect(getNames()).toEqual(['row-00', 'row-01', 'row-02', 'row-03', 'row-04']);
fireEvent.click(screen.getByRole('button', { name: '2' }));
expect(getNames()).toEqual(['row-05', 'row-06', 'row-07', 'row-08', 'row-09']);
fireEvent.click(screen.getByRole('button', { name: '3' }));
expect(getNames()).toEqual(['row-10', 'row-11']);
});
it('shows all rows and no pagination when pagination is disabled', () => {
renderTable({ data: makeRows(11), pagination: false });
expect(getNames()).toHaveLength(11);
expect(screen.queryByRole('button', { name: '2' })).toBeNull();
expect(screen.queryByTestId('page-size-select')).toBeNull();
});
it('hides the pagination footer when the data fits on one page', () => {
renderTable({ data: makeRows(10) });
expect(screen.queryByRole('button', { name: '2' })).toBeNull();
expect(screen.queryByTestId('page-size-select')).toBeNull();
});
it('changes the page size and returns to the first page', () => {
renderTable({
data: makeRows(12),
pageSize: 5,
onPageSizeChange: mocks.onPageSizeChange,
});
fireEvent.click(screen.getByRole('button', { name: '2' }));
expect(getNames()[0]).toBe('row-05');
fireEvent.change(screen.getByTestId('page-size-select'), { target: { value: '10' } });
expect(mocks.onPageSizeChange).toHaveBeenCalledWith(10);
expect(getNames()[0]).toBe('row-00');
});
it('moves to the previous page when the last row of the current page is removed', async () => {
const [rows, setRows] = createSignal(makeRows(11));
render(() => (
<Table<Row>
data={rows()}
columns={columns}
pageSize={10}
emptyTable={<div>empty</div>}
/>
));
fireEvent.click(screen.getByRole('button', { name: '2' }));
expect(screen.getByText('row-10')).toBeDefined();
setRows(makeRows(10));
expect(await screen.findByText('row-09')).toBeDefined();
expect(screen.getByText('row-00')).toBeDefined();
expect(screen.queryByText('empty')).toBeNull();
// The clamp must land on page 0, not on a stale index.
expect(screen.queryByRole('button', { name: '2' })).toBeNull();
});
});
describe('row interactions', () => {
it('calls onRowClick with the clicked row', () => {
const row = { name: 'Alice', age: 30 };
renderTable({ data: [row], onRowClick: mocks.onRowClick });
fireEvent.click(screen.getByText('Alice'));
expect(mocks.onRowClick).toHaveBeenCalledWith(row);
});
it('renders rows as links when rowLink returns a target', () => {
const history = createMemoryHistory();
history.set({ value: '/' });
render(() => (
<MemoryRouter history={history}>
<Route
path="/*"
component={() => (
<Table<Row>
data={[{ name: 'Alice', age: 30 }]}
columns={columns}
rowLink={() => ({ to: 'DnsRewrites', query: { foo: 'bar' } })}
/>
)}
/>
</MemoryRouter>
));
expect(screen.getByRole('link').getAttribute('href')).toBe('/dns_rewrites?foo=bar');
});
});
describe('layout', () => {
it('applies custom table, header and row classes', () => {
const { container } = renderTable({
data: [{ name: 'Alice', age: 30 }],
class: 'custom-table',
tableHeaderClass: 'custom-header',
tableRowClass: 'custom-row',
});
expect(container.querySelector('.custom-table')).not.toBeNull();
expect(container.querySelector('.custom-header')).not.toBeNull();
expect(container.querySelector('.custom-row')).not.toBeNull();
});
it('applies a custom class to body cells', () => {
const { container } = renderTable({ data: [{ name: 'Alice', age: 30 }] });
expect(container.querySelector('.name-column-cell')).not.toBeNull();
});
it('builds the grid template from column widths', () => {
const { container } = renderTable({
data: [{ name: 'Alice', age: 30 }],
columns: [
{ key: 'a', header: { text: 'A' }, accessor: 'name', width: 100 },
{ key: 'b', header: { text: 'B' }, accessor: 'age', width: '20%' },
{ key: 'c', header: { text: 'C' }, accessor: 'city', fitContent: true },
{
key: 'd',
header: { text: 'D' },
accessor: 'name',
minWidth: 80,
maxWidth: 200,
},
{ key: 'e', header: { text: 'E' }, accessor: 'age' },
],
tableHeaderClass: 'grid-header',
});
const header = getHeader(container, '.grid-header');
expect(header.style.getPropertyValue('--table-columns')).toBe(
'100px 20% fit-content(100%) minmax(80px, 200px) minmax(0, 1fr)',
);
});
});
});
@@ -0,0 +1,537 @@
import { render, screen, fireEvent, within, waitFor } from '@solidjs/testing-library';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Cache } from 'panel/components/DnsSettings/Cache';
import { CacheInputDialog } from 'panel/components/DnsSettings/Cache/blocks/CacheInputDialog';
import { getCacheSizeSummary, getTtlSummary } from 'panel/components/DnsSettings/helpers';
import { UINT32_RANGE } from 'panel/helpers/constants';
import { dnsConfigState, getDnsConfig } from 'panel/stores/dnsConfig';
import { copy, copyInDom, type CopyKey } from 'panel/__tests__/helpers/copy';
type DnsInfo = {
cache_enabled?: boolean;
cache_size: number;
cache_ttl_min: number;
cache_ttl_max: number;
cache_optimistic: boolean;
};
const mocks = vi.hoisted(() => ({
apiDnsInfo: vi.fn(),
apiDnsConfig: vi.fn(),
apiCacheClear: vi.fn(),
}));
vi.mock('panel/api/generated', () => ({
dnsInfo: mocks.apiDnsInfo,
dnsConfig: mocks.apiDnsConfig,
cacheClear: mocks.apiCacheClear,
}));
vi.mock('panel/stores/toasts', () => ({
addErrorToast: vi.fn(),
addSuccessToast: vi.fn(),
}));
vi.mock('panel/common/intl', async () =>
(await import('panel/__tests__/helpers/copy')).createIntlMock(),
);
const defaultConfig: DnsInfo = {
cache_enabled: false,
cache_size: 0,
cache_ttl_min: 0,
cache_ttl_max: 0,
cache_optimistic: false,
};
const renderCache = async (overrides: Partial<DnsInfo> = {}) => {
mocks.apiDnsInfo.mockResolvedValue({ ...defaultConfig, ...overrides });
await getDnsConfig();
render(() => <Cache />);
};
const getRow = (title: CopyKey) => {
const label = screen
.getAllByText(copyInDom(title))
.find((element) => !element.closest('[role="dialog"]'));
if (!label) {
throw new Error(`Cache row not found: ${title}`);
}
const row = label.closest('[role="button"]');
if (!(row instanceof HTMLElement)) {
throw new Error(`Cache row is not an element: ${title}`);
}
return row;
};
const getDialog = (title: CopyKey) => {
const dialogTitle = `${title}_title` as CopyKey;
const label = screen
.getAllByText(copyInDom(dialogTitle))
.find((element) => element.closest('[role="dialog"]'));
if (!label) {
throw new Error(`Cache dialog title not found: ${title}`);
}
const dialog = label.closest('[role="dialog"]');
if (!(dialog instanceof HTMLElement)) {
throw new Error(`Cache dialog is not an element: ${title}`);
}
return dialog;
};
const getSwitch = (id: string) => {
const input = document.querySelector(`input#${id}`);
if (!(input instanceof HTMLInputElement)) {
throw new Error(`Cache switch not found: ${id}`);
}
return input;
};
const openDialog = async (title: CopyKey) => {
fireEvent.click(getRow(title));
const dialog = getDialog(title);
const input = await within(dialog).findByRole('spinbutton');
if (!(input instanceof HTMLInputElement)) {
throw new Error(`Cache dialog input not found: ${title}`);
}
return { dialog, input };
};
const saveDialog = (dialog: HTMLElement) => {
fireEvent.click(within(dialog).getByTestId('config-dialog-save'));
};
const waitForSave = () => waitFor(() => expect(dnsConfigState.processingSetConfig).toBe(false));
const deferred = <T,>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
};
describe('Cache', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.apiDnsConfig.mockResolvedValue(undefined);
mocks.apiCacheClear.mockResolvedValue(undefined);
});
describe('rendering', () => {
it('renders the cache settings and their current values', async () => {
await renderCache({
cache_enabled: true,
cache_size: 4194304,
cache_ttl_min: 60,
cache_ttl_max: 3600,
cache_optimistic: true,
});
expect(screen.getByText(copyInDom('dns_cache_title'))).toBeDefined();
expect(getRow('dns_cache_size')).toBeDefined();
expect(screen.getByText('4 MB')).toBeDefined();
expect(screen.getByText(copyInDom('dns_ttl_value', { value: 60 }))).toBeDefined();
expect(screen.getByText(copyInDom('dns_ttl_value', { value: 3600 }))).toBeDefined();
expect(getSwitch('cache_enabled').checked).toBe(true);
expect(getSwitch('optimistic_caching').checked).toBe(true);
});
it('disables optimistic caching while the cache is disabled', async () => {
await renderCache({ cache_enabled: false });
expect(getSwitch('optimistic_caching').disabled).toBe(true);
});
it('enables optimistic caching when the cache is enabled', async () => {
await renderCache({ cache_enabled: true });
expect(getSwitch('optimistic_caching').disabled).toBe(false);
});
it('shows a zero cache size summary', async () => {
await renderCache({ cache_size: 0 });
expect(screen.getByText('0 bytes')).toBeDefined();
});
it('disables the cache size row while the cache state is unknown', async () => {
await renderCache({ cache_enabled: undefined });
expect(getRow('dns_cache_size')).toHaveAttribute('tabindex', '-1');
});
});
describe('value rows', () => {
it.each(['dns_cache_size', 'dns_override_min_ttl', 'dns_override_max_ttl'] as const)(
'allows editing %s while the cache is disabled',
async (title) => {
await renderCache();
const row = getRow(title);
expect(row).toHaveAttribute('tabindex', '0');
fireEvent.click(row);
const dialog = getDialog(title);
expect(await within(dialog).findByRole('spinbutton')).toBeDefined();
},
);
it('prefills the dialog with the stored value', async () => {
await renderCache({ cache_size: 1048576 });
const { input } = await openDialog('dns_cache_size');
await waitFor(() => expect(input.value).toBe('1048576'));
});
});
describe('saving values', () => {
it('keeps the cache switch state when saving the size while the cache is disabled', async () => {
await renderCache({ cache_enabled: false });
const { dialog, input } = await openDialog('dns_cache_size');
fireEvent.change(input, { target: { value: '4194304' } });
saveDialog(dialog);
await waitFor(() =>
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({
cache_size: 4194304,
cache_enabled: false,
}),
);
await waitForSave();
});
it('keeps the cache switch state when saving the size while the cache is enabled', async () => {
await renderCache({ cache_enabled: true, cache_size: 1024 });
const { dialog, input } = await openDialog('dns_cache_size');
fireEvent.change(input, { target: { value: '1048576' } });
saveDialog(dialog);
await waitFor(() =>
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({
cache_size: 1048576,
cache_enabled: true,
}),
);
await waitForSave();
});
it('omits cache_enabled when it is unknown', async () => {
mocks.apiDnsInfo.mockResolvedValue({
cache_enabled: undefined,
cache_size: 0,
cache_ttl_min: 0,
cache_ttl_max: 0,
cache_optimistic: false,
});
await getDnsConfig();
render(() => (
<CacheInputDialog
configKey="cache_size"
open={() => true}
onClose={vi.fn()}
processing={false}
/>
));
const dialog = getDialog('dns_cache_size');
const input = await within(dialog).findByRole('spinbutton');
fireEvent.change(input, { target: { value: '1048576' } });
saveDialog(dialog);
await waitFor(() =>
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_size: 1048576 }),
);
await waitForSave();
});
it('saves the minimum TTL override', async () => {
await renderCache({ cache_ttl_min: 0, cache_ttl_max: 3600 });
const { dialog, input } = await openDialog('dns_override_min_ttl');
fireEvent.change(input, { target: { value: '60' } });
saveDialog(dialog);
await waitFor(() =>
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_ttl_min: 60 }),
);
await waitForSave();
});
it('saves the maximum TTL override', async () => {
await renderCache({ cache_ttl_min: 0, cache_ttl_max: 0 });
const { dialog, input } = await openDialog('dns_override_max_ttl');
fireEvent.change(input, { target: { value: '3600' } });
saveDialog(dialog);
await waitFor(() =>
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_ttl_max: 3600 }),
);
await waitForSave();
});
});
describe('validation', () => {
it('rejects an empty cache size', async () => {
await renderCache({ cache_size: 1024 });
const { dialog, input } = await openDialog('dns_cache_size');
fireEvent.change(input, { target: { value: '' } });
saveDialog(dialog);
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
expect(within(dialog).getByText(copyInDom('form_error_required'))).toBeDefined();
});
it('rejects a zero cache size while the cache is enabled', async () => {
await renderCache({ cache_enabled: true, cache_size: 1024 });
const { dialog, input } = await openDialog('dns_cache_size');
fireEvent.change(input, { target: { value: '0' } });
saveDialog(dialog);
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
expect(
within(dialog).getByText(copyInDom('cache_config_size_validation')),
).toBeDefined();
});
it('allows a zero cache size while the cache is disabled', async () => {
await renderCache({ cache_enabled: false, cache_size: 1024 });
const { dialog, input } = await openDialog('dns_cache_size');
fireEvent.change(input, { target: { value: '0' } });
saveDialog(dialog);
await waitFor(() =>
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({
cache_size: 0,
cache_enabled: false,
}),
);
await waitForSave();
});
it.each([false, true])(
'rejects a fractional cache size when cache_enabled is %s',
async (enabled) => {
await renderCache({ cache_enabled: enabled, cache_size: 1024 });
const { dialog, input } = await openDialog('dns_cache_size');
fireEvent.change(input, { target: { value: '0.5' } });
saveDialog(dialog);
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
copyInDom('form_value_value_from_error', {
min_value: UINT32_RANGE.MIN.toLocaleString(),
max_value: UINT32_RANGE.MAX.toLocaleString(),
}),
),
).toBeDefined();
},
);
it.each(['dns_override_min_ttl', 'dns_override_max_ttl'] as const)(
'rejects a fractional %s value',
async (title) => {
await renderCache();
const { dialog, input } = await openDialog(title);
fireEvent.change(input, { target: { value: '0.5' } });
saveDialog(dialog);
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
copyInDom('form_value_value_from_error', {
min_value: UINT32_RANGE.MIN.toLocaleString(),
max_value: UINT32_RANGE.MAX.toLocaleString(),
}),
),
).toBeDefined();
},
);
it('rejects a cache size above the uint32 range', async () => {
await renderCache({ cache_size: 1024 });
const { dialog, input } = await openDialog('dns_cache_size');
fireEvent.change(input, { target: { value: '4294967296' } });
saveDialog(dialog);
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
copyInDom('form_value_value_from_error', {
min_value: UINT32_RANGE.MIN.toLocaleString(),
max_value: UINT32_RANGE.MAX.toLocaleString(),
}),
),
).toBeDefined();
});
it('rejects a minimum TTL above the maximum', async () => {
await renderCache({ cache_ttl_min: 0, cache_ttl_max: 100 });
const { dialog, input } = await openDialog('dns_override_min_ttl');
fireEvent.change(input, { target: { value: '200' } });
saveDialog(dialog);
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
expect(
within(dialog).getByText(copyInDom('cache_config_ttl_validation')),
).toBeDefined();
});
it('rejects a maximum TTL below the minimum', async () => {
await renderCache({ cache_ttl_min: 100, cache_ttl_max: 200 });
const { dialog, input } = await openDialog('dns_override_max_ttl');
fireEvent.change(input, { target: { value: '50' } });
saveDialog(dialog);
expect(mocks.apiDnsConfig).not.toHaveBeenCalled();
expect(
within(dialog).getByText(copyInDom('cache_config_ttl_validation')),
).toBeDefined();
});
});
describe('switches', () => {
it('enables the cache', async () => {
await renderCache({ cache_enabled: false });
fireEvent.click(getSwitch('cache_enabled'));
await waitFor(() =>
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_enabled: true }),
);
await waitForSave();
});
it('disables the cache', async () => {
await renderCache({ cache_enabled: true });
fireEvent.click(getSwitch('cache_enabled'));
await waitFor(() =>
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_enabled: false }),
);
await waitForSave();
});
it('enables optimistic caching', async () => {
await renderCache({ cache_enabled: true, cache_optimistic: false });
fireEvent.click(getSwitch('optimistic_caching'));
await waitFor(() =>
expect(mocks.apiDnsConfig).toHaveBeenCalledWith({ cache_optimistic: true }),
);
await waitForSave();
});
it('reverts the cache switch when saving fails', async () => {
mocks.apiDnsConfig.mockRejectedValueOnce(new Error('failed'));
await renderCache({ cache_enabled: false });
const input = getSwitch('cache_enabled');
fireEvent.click(input);
expect(input.checked).toBe(true);
await waitFor(() => expect(input.checked).toBe(false));
});
it('ignores a second toggle while a save is in flight', async () => {
const pending = deferred<void>();
mocks.apiDnsConfig.mockReturnValueOnce(pending.promise);
await renderCache({ cache_enabled: false });
const input = getSwitch('cache_enabled');
fireEvent.click(input);
fireEvent.click(input);
expect(mocks.apiDnsConfig).toHaveBeenCalledTimes(1);
pending.resolve();
await waitFor(() => expect(dnsConfigState.processingSetConfig).toBe(false));
});
});
describe('clear cache', () => {
it('clears the cache after confirmation', async () => {
await renderCache();
fireEvent.click(screen.getByText(copyInDom('dns_clear_cache')));
fireEvent.click(screen.getByText(copyInDom('dns_clear_cache_confirm')));
expect(mocks.apiCacheClear).toHaveBeenCalledTimes(1);
});
it('does not clear the cache when cancelled', async () => {
await renderCache();
fireEvent.click(screen.getByText(copyInDom('dns_clear_cache')));
fireEvent.click(screen.getByText(copyInDom('cancel')));
expect(mocks.apiCacheClear).not.toHaveBeenCalled();
});
});
});
describe('cache summaries', () => {
it.each([
[0, '0 bytes'],
[512, '512 bytes'],
[2048, '2 KB'],
[1048576, '1 MB'],
[4194304, '4 MB'],
])('formats %i bytes as %s', (bytes, expected) => {
expect(getCacheSizeSummary(bytes)).toBe(expected);
});
it('returns an empty cache size summary for missing values', () => {
expect(getCacheSizeSummary(undefined)).toBe('');
});
it('formats the TTL summary', () => {
expect(getTtlSummary(60)).toBe(copy('dns_ttl_value', { value: 60 }));
});
it('returns an empty TTL summary for missing values', () => {
expect(getTtlSummary(undefined)).toBe('');
});
});
+1 -2
View File
@@ -145,8 +145,7 @@ export const Menu = (props: Props) => {
<div class={cn(s.menuLinkWrapper)}>
<a
href={getLogoutUrl()}
target="_blank"
rel="noopener noreferrer"
rel="external"
class={s.menuLink}
id="sign_out"
>
+13 -1
View File
@@ -1,4 +1,4 @@
import { type JSX, createMemo, For, Show, untrack } from 'solid-js';
import { type JSX, createEffect, createMemo, For, Show, untrack } from 'solid-js';
import { createStore } from 'solid-js/store';
import cn from 'clsx';
@@ -137,6 +137,18 @@ export const Table = <T extends Record<string, any>>(props: TableProps<T>) => {
const totalPages = () => Math.ceil(sortedData().length / state.pageSize);
// Keep the current page in range when rows are deleted from the last page.
createEffect(() => {
if (props.loading) {
return;
}
const lastPage = Math.max(0, totalPages() - 1);
if (state.currentPage > lastPage) {
setState('currentPage', lastPage);
}
});
const tableGridTemplate = createMemo(() =>
props.columns
.map((column) => {
@@ -71,7 +71,9 @@ export const Cache = () => {
title={intl.getMessage('dns_cache_size')}
description={intl.getMessage('dns_cache_size_desc')}
value={cacheSizeValue()}
disabled={!dnsConfigState.cache_enabled}
// Saving a size without a known switch state would make the
// backend infer it and silently change the cache state.
disabled={dnsConfigState.cache_enabled === undefined}
onClick={cacheSizeDialog.openDialog}
/>
@@ -81,7 +83,6 @@ export const Cache = () => {
title={intl.getMessage('dns_override_min_ttl')}
description={intl.getMessage('dns_override_min_ttl_desc')}
value={minTtlValue()}
disabled={!dnsConfigState.cache_enabled}
onClick={minTtlDialog.openDialog}
/>
@@ -91,7 +92,6 @@ export const Cache = () => {
title={intl.getMessage('dns_override_max_ttl')}
description={intl.getMessage('dns_override_max_ttl_desc')}
value={maxTtlValue()}
disabled={!dnsConfigState.cache_enabled}
onClick={maxTtlDialog.openDialog}
/>
@@ -4,7 +4,11 @@ import intl from 'panel/common/intl';
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
import { Input } from 'panel/common/controls/Input';
import { UINT32_RANGE } from 'panel/helpers/constants';
import { validateBetween, validateRequiredValue } from 'panel/helpers/validators';
import {
validateBetween,
validateCacheSize,
validateRequiredValue,
} from 'panel/helpers/validators';
import { useField } from 'panel/hooks/useField';
import theme from 'panel/lib/theme';
@@ -33,7 +37,8 @@ const CACHE_DIALOG_CONFIG: Record<
label: () => intl.getMessage('dns_cache_size_label'),
validate: (v) =>
validateRequiredValue(v) ||
validateBetween(Number(v), UINT32_RANGE.MIN, UINT32_RANGE.MAX) ||
validateBetween(Number(v), UINT32_RANGE.MIN, UINT32_RANGE.MAX, { integer: true }) ||
validateCacheSize(Number(v), !!dnsConfigState.cache_enabled) ||
'',
},
cache_ttl_min: {
@@ -45,7 +50,9 @@ const CACHE_DIALOG_CONFIG: Record<
const requiredErr = validateRequiredValue(v);
if (requiredErr) return requiredErr;
const num = Number(v);
const rangeErr = validateBetween(num, UINT32_RANGE.MIN, UINT32_RANGE.MAX);
const rangeErr = validateBetween(num, UINT32_RANGE.MIN, UINT32_RANGE.MAX, {
integer: true,
});
if (rangeErr) return rangeErr;
// Cross-field TTL check: min > max. Only the maximum used to run
// this check, so a minimum above the stored maximum was accepted,
@@ -66,7 +73,9 @@ const CACHE_DIALOG_CONFIG: Record<
const requiredErr = validateRequiredValue(v);
if (requiredErr) return requiredErr;
const num = Number(v);
const rangeErr = validateBetween(num, UINT32_RANGE.MIN, UINT32_RANGE.MAX);
const rangeErr = validateBetween(num, UINT32_RANGE.MIN, UINT32_RANGE.MAX, {
integer: true,
});
if (rangeErr) return rangeErr;
// Cross-field TTL check: min > max
const minVal = Number(dnsConfigState.cache_ttl_min);
@@ -96,7 +105,21 @@ export const CacheInputDialog = (props: Props) => {
onSubmit={() => {
const configKey = props.configKey;
field.submitIfValid((v) => {
setDnsConfig({ [configKey]: v === '' ? 0 : Number(v) });
const value = v === '' ? 0 : Number(v);
const cacheEnabled = dnsConfigState.cache_enabled;
// The backend infers `cache_enabled` from `cache_size` when omitted, so send
// the known flag to keep the switch in sync, and omit it when it is unknown.
setDnsConfig(
configKey === 'cache_size'
? {
cache_size: value,
...(cacheEnabled !== undefined
? { cache_enabled: cacheEnabled }
: {}),
}
: { [configKey]: value },
);
untrack(() => props.onClose());
});
}}
+27 -15
View File
@@ -328,10 +328,7 @@ export const validateClientsPerLine = (value: string): string | undefined =>
validatePerLine(
value,
(line) =>
R_IPV4.test(line) ||
isValidIpv6(line) ||
isValidCidr(line) ||
R_CLIENT_ID.test(line),
R_IPV4.test(line) || isValidIpv6(line) || isValidCidr(line) || R_CLIENT_ID.test(line),
);
/**
@@ -779,8 +776,22 @@ export const validateHostnameNotDuplicate =
return undefined;
};
export const validateBetween = (value: number, min: number, max: number): string | undefined => {
if (value < min || value > max) {
/**
* Validates that a value is within the inclusive range. When `integer` is
* true, non-integer values are rejected as well.
*/
export const validateBetween = (
value: number,
min: number,
max: number,
options?: { integer?: boolean },
): string | undefined => {
if (
!Number.isFinite(value) ||
value < min ||
value > max ||
(options?.integer && !Number.isInteger(value))
) {
return intl.getMessage('form_value_value_from_error', {
min_value: min.toLocaleString(),
max_value: max.toLocaleString(),
@@ -826,18 +837,19 @@ export const validateMinValue = (value: number, min: number): string | undefined
};
/**
* Validates the client DNS cache size.
* Callers should gate on `upstreams_cache_enabled` before invoking.
* Returns undefined if valid, or an i18n error message string if invalid.
* Validates a DNS cache size. The value is only checked when `enabled` is
* true: it must be greater than zero and within the uint32 range.
*
* Used for both the per-client upstream cache and the global DNS cache.
*
* @param size - The cache size in bytes
* @param enabled - Whether per-client upstream caching is enabled
* @param enabled - Whether the cache the size belongs to is enabled
*
* @example validateCacheSize(0, true) // error ("must be greater than zero")
* @example validateCacheSize('', true) // error ("must be greater than zero")
* @example validateCacheSize(1000, true) // undefined (valid)
* @example validateCacheSize(0, true) // error ("must be greater than zero")
* @example validateCacheSize('', true) // error ("must be greater than zero")
* @example validateCacheSize(1000, true) // undefined (valid)
* @example validateCacheSize(4294967296, true) // error (exceeds UINT32_MAX)
* @example validateCacheSize(0, false) // undefined (no validation when disabled)
* @example validateCacheSize(0, false) // undefined (no validation when disabled)
*/
export const validateCacheSize = (size: number | string, enabled: boolean): ValidationResult => {
if (!enabled) {
@@ -847,5 +859,5 @@ export const validateCacheSize = (size: number | string, enabled: boolean): Vali
if (Number.isNaN(num) || num === 0) {
return intl.getMessage('cache_config_size_validation');
}
return validateBetween(num, 1, UINT32_RANGE.MAX);
return validateBetween(num, 1, UINT32_RANGE.MAX, { integer: true });
};