fix clients

This commit is contained in:
Ildar Kamalov
2026-06-25 09:39:53 +03:00
parent 27a88f16c8
commit 0e677a7349
9 changed files with 209 additions and 42 deletions
@@ -13,9 +13,8 @@ describe('Identifiers', () => {
render(() => <Identifiers />);
const input = document.querySelector('input[type="text"]') as HTMLInputElement;
// Simulate typing + blur-commit (the Input listens to the native `change`
// event, which fires when the field loses focus).
fireEvent.change(input, { target: { value: '1.2.3.4' } });
// Simulate typing — fireEvent.input triggers onInput, which calls handleChange.
fireEvent.input(input, { target: { value: '1.2.3.4' } });
expect(input.value).toBe('1.2.3.4');
expect(clientFormState.ids).toEqual(['1.2.3.4']);
@@ -35,7 +34,7 @@ describe('Identifiers', () => {
it('keeps existing values when adding a new row', () => {
render(() => <Identifiers />);
const first = document.querySelector('input[type="text"]') as HTMLInputElement;
fireEvent.change(first, { target: { value: '1.2.3.4' } });
fireEvent.input(first, { target: { value: '1.2.3.4' } });
fireEvent.click(screen.getByTestId('client-form-add-identifier'));
@@ -63,10 +62,28 @@ describe('Identifiers', () => {
render(() => <Identifiers />);
const input = document.querySelector('input[type="text"]') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'not-a-valid-id' } });
fireEvent.input(input, { target: { value: 'not-a-valid-id' } });
fireEvent.blur(input);
// The error message element is rendered by the Input when there is an error.
expect(input.value).toBe('not-a-valid-id');
});
it('clears the local error when the user types in the field', () => {
render(() => <Identifiers />);
const input = document.querySelector('input[type="text"]') as HTMLInputElement;
// Trigger a validation error on blur.
fireEvent.input(input, { target: { value: 'bad!!!' } });
fireEvent.blur(input);
// Now type a valid value — fireEvent.input triggers onInput, which calls
// handleChange → clears the local error.
fireEvent.input(input, { target: { value: '1.2.3.4' } });
// The Input component only renders the error div when errorMessage is truthy.
// Check that the wrapper does not have the error modifier class.
const wrapper = input.closest('[class*="inputWrapper"]');
expect(wrapper?.className).not.toContain('error');
});
});
@@ -1,9 +1,29 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi, beforeEach } from 'vitest';
// vi.hoisted ensures the mock is available before module evaluation.
const dashboardMocks = vi.hoisted(() => ({
getClients: vi.fn(),
dashboardState: { clients: [] as any[] },
}));
vi.mock('panel/stores/dashboard', async () => {
const actual =
await vi.importActual<typeof import('panel/stores/dashboard')>('panel/stores/dashboard');
return {
...actual,
getClients: dashboardMocks.getClients,
get dashboardState() {
return dashboardMocks.dashboardState;
},
};
});
import {
initClientForm,
updateClientFormField,
clearClientForm,
clientFormState,
saveClient,
} from 'panel/stores/clientForm';
describe('clientForm store', () => {
@@ -66,3 +86,34 @@ describe('clientForm store', () => {
expect(clientFormState.mode).toBe('add');
});
});
describe('saveClient cross-client validation', () => {
beforeEach(() => {
clearClientForm();
dashboardMocks.dashboardState.clients = [];
});
it('rejects an identifier that already belongs to another client', async () => {
// Set up the mocked dashboard store to include a client with a known ID.
dashboardMocks.dashboardState.clients = [{ name: 'Other', ids: ['192.168.1.50'] } as any];
updateClientFormField({ field: 'name', value: 'New Client' });
updateClientFormField({ field: 'ids', value: ['192.168.1.50'] });
const result = await saveClient();
expect(result).toBe(false);
expect(clientFormState.formErrors.ids).toBeDefined();
});
it('rejects cache size of 0 when cache is enabled', async () => {
clearClientForm();
updateClientFormField({ field: 'name', value: 'Test' });
updateClientFormField({ field: 'ids', value: ['192.168.1.1'] });
updateClientFormField({ field: 'upstreams_cache_enabled', value: true });
updateClientFormField({ field: 'upstreams_cache_size', value: 0 });
const result = await saveClient();
expect(result).toBe(false);
expect(clientFormState.formErrors.upstreams_cache_size).toBeDefined();
});
});
@@ -16,6 +16,7 @@ import {
validateMaxValue,
validateBetween,
validateMinValue,
validateCacheSize,
} from 'panel/helpers/validators';
describe('validateIdentifier', () => {
@@ -395,3 +396,28 @@ describe('numeric range validators', () => {
expect(validateMinValue(10, 1)).toBeUndefined();
});
});
describe('validateCacheSize', () => {
it('returns undefined when cache is disabled', () => {
expect(validateCacheSize(0, false)).toBeUndefined();
expect(validateCacheSize(999999999999, false)).toBeUndefined();
});
it('returns error for 0 when enabled', () => {
const result = validateCacheSize(0, true);
expect(result).toBeTruthy();
});
it('returns undefined for a valid size when enabled', () => {
expect(validateCacheSize(1000, true)).toBeUndefined();
});
it('returns error for value exceeding UINT32_MAX', () => {
const result = validateCacheSize(4294967296, true);
expect(result).toBeTruthy();
});
it('returns undefined at UINT32_MAX boundary', () => {
expect(validateCacheSize(4294967295, true)).toBeUndefined();
});
});
@@ -23,7 +23,7 @@ import {
setFormErrors,
} from 'panel/stores/clientForm';
import { dashboardState, getClients } from 'panel/stores/dashboard';
import { validateUpstreams } from 'panel/helpers/validators';
import { validateUpstreams, validateCacheSize } from 'panel/helpers/validators';
import { RoutePath, Paths } from 'panel/components/Routes/Paths';
import theme from 'panel/lib/theme';
@@ -126,6 +126,16 @@ export const AddClient = () => {
});
};
const handleCacheSizeBlur = () => {
if (!clientFormState.upstreams_cache_enabled) return;
const err = validateCacheSize(clientFormState.upstreams_cache_size, true);
if (err) {
// Merge with existing errors rather than replacing — preserves sibling
// errors like name and ids that were already set.
setFormErrors({ ...clientFormState.formErrors, upstreams_cache_size: err });
}
};
const tagOptions = createMemo(() =>
(dashboardState.supportedTags || []).map((tag: string) => ({
label: tag,
@@ -176,6 +186,12 @@ export const AddClient = () => {
value: (e.target as HTMLInputElement).value,
})
}
onInput={(e: Event) =>
updateClientFormField({
field: 'name',
value: (e.target as HTMLInputElement).value,
})
}
placeholder={intl.getMessage('clients_add_default_name')}
label={intl.getMessage('clients_add_name')}
size="large"
@@ -319,9 +335,17 @@ export const AddClient = () => {
value: Number((e.target as HTMLInputElement).value) || 0,
})
}
onBlur={handleCacheSizeBlur}
placeholder={intl.getMessage('clients_dns_cache_size_placeholder')}
label={intl.getMessage('clients_dns_cache_size')}
size="large"
error={!!clientFormState.formErrors.upstreams_cache_size}
errorMessage={
typeof clientFormState.formErrors.upstreams_cache_size ===
'string'
? clientFormState.formErrors.upstreams_cache_size
: undefined
}
/>
</Show>
</SwitchGroup>
@@ -3,9 +3,11 @@ import cn from 'clsx';
import intl from 'panel/common/intl';
import { Input } from 'panel/common/controls/Input';
import { Icon } from 'panel/common/ui/Icon';
import type { Client } from 'panel/initialState';
import { clientFormState, updateClientFormField } from 'panel/stores/clientForm';
import { dashboardState } from 'panel/stores/dashboard';
import {
clientFormState,
updateClientFormField,
computeExistingClientIds,
} from 'panel/stores/clientForm';
import { validateIdentifier } from 'panel/helpers/validators';
import theme from 'panel/lib/theme';
@@ -30,16 +32,13 @@ export const Identifiers = () => {
const formErrors = clientFormState.formErrors;
if (Array.isArray(formErrors.ids)) {
setErrors(formErrors.ids);
} else {
// Store errors were cleared — clear stale local errors too.
setErrors((prev) => prev.map((): undefined => undefined));
}
});
const existingClientIds = createMemo(() => {
const clients: Client[] = dashboardState.clients || [];
const isEdit = clientFormState.mode === 'edit';
return clients
.filter((c) => !isEdit || c.name !== clientFormState.originalName)
.flatMap((c) => c.ids);
});
const existingClientIds = createMemo(() => computeExistingClientIds());
const handleAdd = () => {
updateClientFormField('ids', [...clientFormState.ids, '']);
@@ -56,6 +55,12 @@ export const Identifiers = () => {
const newIds = [...clientFormState.ids];
newIds[index] = value;
updateClientFormField('ids', newIds);
// Clear the local error for this index — re-validated on blur.
setErrors((prev) => {
const next = [...prev];
next[index] = undefined;
return next;
});
};
const handleBlur = (index: number) => {
@@ -100,6 +105,9 @@ export const Identifiers = () => {
onChange={(e: Event) =>
handleChange(index, (e.target as HTMLInputElement).value)
}
onInput={(e: Event) =>
handleChange(index, (e.target as HTMLInputElement).value)
}
onBlur={() => handleBlur(index)}
placeholder={intl.getMessage('clients_identifier_format_error')}
error={!!activeError()}
@@ -101,6 +101,7 @@
flex-shrink: 0;
color: var(--default-gray-icons);
transition: color var(--t2);
outline: none;
&:hover {
color: var(--default-main-text);
@@ -54,12 +54,15 @@ export const PersistentClientsTable = (props: Props) => {
text: intl.getMessage('client_identifier'),
className: s.headerCell,
},
accessor: (row: Client) => row.ids.join(','),
accessor: (row: Client) => row.ids.filter((id) => id.trim() !== '').join(','),
sortable: true,
render: (_value: string, row: Client) => {
const { ids } = row;
const firstId = ids[0] || '';
const hiddenCount = ids.length - 1;
// Filter out empty strings — the backend may return trailing empty
// entries that would inflate hiddenCount and cause a spurious comma.
const nonEmpty = ids.filter((id) => id.trim() !== '');
const firstId = nonEmpty[0] || '';
const hiddenCount = nonEmpty.length - 1;
return (
<div class={s.cell}>
@@ -69,7 +72,6 @@ export const PersistentClientsTable = (props: Props) => {
<div class={s.idsRow}>
<span class={cn(theme.common.textOverflow, s.idsText)}>
{firstId}
<Show when={hiddenCount > 0}>,</Show>
</span>
<Show when={hiddenCount > 0}>
<Dropdown
@@ -78,7 +80,7 @@ export const PersistentClientsTable = (props: Props) => {
overlayClass={s.idsTooltipOverlay}
menu={
<div class={s.idsTooltip}>
<For each={ids}>
<For each={nonEmpty}>
{(id) => (
<span class={s.idsTooltipItem}>{id}</span>
)}
@@ -90,7 +92,7 @@ export const PersistentClientsTable = (props: Props) => {
s.copyBtnGreen,
s.copyBtnTopRight,
)}
onClick={() => handleCopy(ids.join(', '))}
onClick={() => handleCopy(nonEmpty.join(', '))}
title={intl.getMessage('copy')}
>
<Icon icon="copy" color="green" />
@@ -218,9 +220,11 @@ export const PersistentClientsTable = (props: Props) => {
text: intl.getMessage('tags_title'),
className: s.headerCell,
},
accessor: 'tags',
sortable: false,
render: (value: string[]) => <TagCell tags={value} onCopy={handleCopy} />,
accessor: (row: Client) => row.tags.join(','),
sortable: true,
render: (_value: string, row: Client) => (
<TagCell tags={row.tags} onCopy={handleCopy} />
),
},
{
key: 'requests',
+27 -13
View File
@@ -15,6 +15,7 @@ import {
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
R_HOSTNAME,
UINT32_RANGE,
} from './constants';
import { ip4ToInt, isValidAbsolutePath } from './form';
@@ -598,10 +599,7 @@ export const validateMacNotDuplicate =
return undefined;
};
export const validateMaxValue = (
value: number,
max: number,
): string | undefined => {
export const validateMaxValue = (value: number, max: number): string | undefined => {
if (value > max) {
return intl.getMessage('form_value_length_common_error', {
max_length: String(max),
@@ -610,11 +608,7 @@ export const validateMaxValue = (
return undefined;
};
export const validateBetween = (
value: number,
min: number,
max: number,
): string | undefined => {
export const validateBetween = (value: number, min: number, max: number): string | undefined => {
if (value < min || value > max) {
return intl.getMessage('form_value_value_between_error', {
min_value: String(min),
@@ -624,10 +618,7 @@ export const validateBetween = (
return undefined;
};
export const validateMinValue = (
value: number,
min: number,
): string | undefined => {
export const validateMinValue = (value: number, min: number): string | undefined => {
if (value < min) {
return intl.getMessage('form_value_value_min_error', {
min_value: String(min),
@@ -635,3 +626,26 @@ export const validateMinValue = (
}
return 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.
*
* @param size - The cache size in bytes
* @param enabled - Whether per-client upstream caching is enabled
*
* @example validateCacheSize(0, 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)
*/
export const validateCacheSize = (size: number, enabled: boolean): ValidationResult => {
if (!enabled) {
return undefined;
}
if (size === 0) {
return intl.getMessage('cache_config_size_validation');
}
return validateMaxValue(size, UINT32_RANGE.MAX);
};
+25 -3
View File
@@ -3,9 +3,9 @@ import { untrack } from 'solid-js';
import type { ClientFormState, Client } from 'panel/initialState';
import { apiClient } from 'panel/api/Api';
import intl from 'panel/common/intl';
import { validateIdentifier } from 'panel/helpers/validators';
import { validateIdentifier, validateCacheSize } from 'panel/helpers/validators';
import { addErrorToast, addSuccessToast } from './toasts';
import { getClients } from './dashboard';
import { getClients, dashboardState } from './dashboard';
const getInitialClientFormState = (): ClientFormState => ({
mode: 'add',
@@ -138,6 +138,15 @@ export const buildClientConfig = (form: ClientFormState) => ({
upstreams_cache_size: form.upstreams_cache_size,
});
/**
* Returns all identifier strings from other persistent clients, excluding the
* client currently being edited.
*/
export const computeExistingClientIds = (): string[] =>
(dashboardState.clients || [])
.filter((c: Client) => state.mode !== 'edit' || c.name !== state.originalName)
.flatMap((c: Client) => c.ids);
export const saveClient = async (): Promise<boolean> => {
const errors: Record<string, string | string[]> = {};
@@ -145,16 +154,29 @@ export const saveClient = async (): Promise<boolean> => {
errors.name = intl.getMessage('form_error_required');
}
const existingClientIds = computeExistingClientIds();
const idErrors = state.ids.map((id: string, index: number) => {
if (!id.trim()) {
return intl.getMessage('form_error_required');
}
return validateIdentifier(id, state.ids, index);
return validateIdentifier(id, state.ids, index, existingClientIds);
});
if (idErrors.some((e: string | undefined) => e !== undefined)) {
errors.ids = idErrors as string[];
}
// Validate cache size when per-client cache is enabled (and not using global settings).
// When use_global_settings is true, upstream settings are inherited; the cache size
// value is still sent to the API but the backend ignores it. We skip validation to
// avoid false positives on stale/inherited values.
if (!state.use_global_settings && state.upstreams_cache_enabled) {
const cacheErr = validateCacheSize(state.upstreams_cache_size, true);
if (cacheErr) {
errors.upstreams_cache_size = cacheErr;
}
}
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return false;