Pull request 2692: AGDNS-4172 update general settings
Some checks failed
build / test (macOS-latest) (push) Has been cancelled
build / test (ubuntu-latest) (push) Has been cancelled
build / test (windows-latest) (push) Has been cancelled
lint / go-lint (push) Has been cancelled
lint / eslint (push) Has been cancelled
build / build-release (push) Has been cancelled
build / notify (push) Has been cancelled
lint / notify (push) Has been cancelled

https://agh.thinkweb.pro/#/settings

Squashed commit of the following:

commit 4cbf10e68a
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Jul 7 11:44:00 2026 +0300

    fix value display

commit 5b9baa8afd
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 18:38:43 2026 +0300

    fix button color

commit 64a840d660
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 18:37:40 2026 +0300

    fix button color

commit 401cbf8dcf
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 18:36:56 2026 +0300

    fix button width

commit 4b3070a0e4
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 16:03:00 2026 +0300

    review fix

commit ebb5a5a675
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 15:27:44 2026 +0300

    missing toast

commit 7fd5553b74
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 15:18:14 2026 +0300

    fix ignored domains

commit 243713b77e
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 14:57:27 2026 +0300

    fix title

commit 9e430938ae
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 13:51:11 2026 +0300

    review fix

commit 2ca383b926
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 12:18:26 2026 +0300

    review fix

commit da1fad5deb
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 11:34:58 2026 +0300

    mv to common

commit 6c47d8a03b
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 11:02:41 2026 +0300

    rm unused

commit 22258dca29
Author: Ildar Kamalov <ik@adguard.com>
Date:   Mon Jul 6 10:45:43 2026 +0300

    AGDNS-4172 update general settings
This commit is contained in:
Ildar Kamalov 2026-07-07 09:02:12 +00:00
parent 6aea8e6093
commit f49ee9fb79
32 changed files with 1459 additions and 542 deletions

View file

@ -0,0 +1,119 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent, screen } from '@solidjs/testing-library';
const { themeMock } = vi.hoisted(() => {
const proxy: any = new Proxy(
{},
{
get: (_target, prop) => {
if (prop === Symbol.toPrimitive || prop === 'toString') {
return () => '';
}
return proxy;
},
},
);
return { themeMock: proxy };
});
vi.mock('panel/lib/theme', () => ({
default: themeMock,
}));
vi.mock('panel/common/intl', () => ({
default: {
getMessage: (key: string, _values?: Record<string, unknown>) => key,
},
}));
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
describe('ConfigDialog', () => {
it('renders children when open', () => {
render(() => (
<ConfigDialog open={true} title="Test Dialog" onClose={vi.fn()} onSubmit={vi.fn()}>
<div data-testid="body-content">Body Content</div>
</ConfigDialog>
));
expect(screen.getByText('Body Content')).toBeDefined();
expect(screen.getByText('Test Dialog')).toBeDefined();
});
it('save disabled when processing is true', () => {
render(() => (
<ConfigDialog
open={true}
title="Test"
onClose={vi.fn()}
onSubmit={vi.fn()}
processing={true}
>
<div>Content</div>
</ConfigDialog>
));
const saveButton = screen.getByTestId('config-dialog-save');
expect(saveButton).toBeDisabled();
});
it('save disabled when submitDisabled is true', () => {
render(() => (
<ConfigDialog
open={true}
title="Test"
onClose={vi.fn()}
onSubmit={vi.fn()}
submitDisabled={true}
>
<div>Content</div>
</ConfigDialog>
));
const saveButton = screen.getByTestId('config-dialog-save');
expect(saveButton).toBeDisabled();
});
it('clicking save fires onSubmit', () => {
const onSubmit = vi.fn();
render(() => (
<ConfigDialog open={true} title="Test" onClose={vi.fn()} onSubmit={onSubmit}>
<div>Content</div>
</ConfigDialog>
));
const saveButton = screen.getByTestId('config-dialog-save');
fireEvent.click(saveButton);
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it('renders footer before save button', () => {
render(() => (
<ConfigDialog
open={true}
title="Test"
onClose={vi.fn()}
onSubmit={vi.fn()}
footer={<button data-testid="secondary-action">Secondary</button>}
>
<div>Content</div>
</ConfigDialog>
));
expect(screen.getByTestId('secondary-action')).toBeDefined();
// The footer div contains both secondary and save button
const footer = screen.getByTestId('secondary-action').parentElement;
expect(footer?.querySelector('[data-testid="config-dialog-save"]')).toBeDefined();
});
it('processing disables fieldset', () => {
render(() => (
<ConfigDialog
open={true}
title="Test"
onClose={vi.fn()}
onSubmit={vi.fn()}
processing={true}
>
<input data-testid="test-input" />
</ConfigDialog>
));
const fieldset = screen.getByTestId('test-input').closest('fieldset');
expect(fieldset).toBeDisabled();
});
});

View file

@ -0,0 +1,214 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent, screen } from '@solidjs/testing-library';
const { themeMock } = vi.hoisted(() => {
const proxy: any = new Proxy(
{},
{
get: (_target, prop) => {
if (prop === Symbol.toPrimitive || prop === 'toString') {
return () => '';
}
return proxy;
},
},
);
return { themeMock: proxy };
});
vi.mock('panel/lib/theme', () => ({
default: themeMock,
}));
vi.mock('panel/common/intl', () => ({
default: {
getMessage: (key: string, _values?: Record<string, unknown>) => key,
},
}));
import { SettingRow } from 'panel/common/ui/SettingRow';
describe('SettingRow', () => {
describe('switch variant', () => {
it('renders title and description', () => {
render(() => (
<SettingRow
id="test-switch"
variant="switch"
title="Test Title"
description="Test Description"
/>
));
expect(screen.getByText('Test Title')).toBeDefined();
expect(screen.getByText('Test Description')).toBeDefined();
});
it('fires onChange when row is clicked', () => {
const onChange = vi.fn();
render(() => (
<SettingRow
id="test-switch"
variant="switch"
title="Title"
checked={false}
onChange={onChange}
/>
));
const row = screen.getByRole('button');
fireEvent.click(row);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(true);
});
it('fires onChange when switch is toggled off', () => {
const onChange = vi.fn();
render(() => (
<SettingRow
id="test-switch"
variant="switch"
title="Title"
checked={true}
onChange={onChange}
/>
));
const row = screen.getByRole('button');
fireEvent.click(row);
expect(onChange).toHaveBeenCalledWith(false);
});
it('does not fire onChange when disabled', () => {
const onChange = vi.fn();
render(() => (
<SettingRow
id="test-switch"
variant="switch"
title="Title"
checked={false}
disabled={true}
onChange={onChange}
/>
));
const row = screen.getByRole('button');
fireEvent.click(row);
expect(onChange).not.toHaveBeenCalled();
});
});
describe('link variant', () => {
it('renders value in semibold', () => {
render(() => (
<SettingRow
id="test-link"
variant="link"
title="Retention"
value="90 days · 3 ignored domains"
onClick={vi.fn()}
/>
));
expect(screen.getByText('90 days · 3 ignored domains')).toBeDefined();
});
it('fires onClick when link is clicked', () => {
const onClick = vi.fn();
render(() => (
<SettingRow
id="test-link"
variant="link"
title="Retention"
value="Summary"
onClick={onClick}
/>
));
const rows = screen.getAllByRole('button');
fireEvent.click(rows[0]);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('does not fire onClick when disabled', () => {
const onClick = vi.fn();
render(() => (
<SettingRow
id="test-link"
variant="link"
title="Retention"
value="Summary"
disabled={true}
onClick={onClick}
/>
));
const rows = screen.getAllByRole('button');
fireEvent.click(rows[0]);
expect(onClick).not.toHaveBeenCalled();
});
});
describe('switch-link variant', () => {
it('renders switch without arrow or configure label', () => {
render(() => (
<SettingRow
id="test-combo"
variant="switch-link"
title="Safe Search"
description="Block inappropriate content"
checked={true}
value="Enabled · 4 providers"
onChange={vi.fn()}
onClick={vi.fn()}
/>
));
expect(screen.queryByText('settings_configure')).toBeNull();
expect(screen.getByText('Enabled · 4 providers')).toBeDefined();
});
it('row click fires onClick, not onChange', () => {
const onChange = vi.fn();
const onClick = vi.fn();
render(() => (
<SettingRow
id="test-combo"
variant="switch-link"
title="Title"
checked={true}
onChange={onChange}
onClick={onClick}
/>
));
const rows = screen.getAllByRole('button');
fireEvent.click(rows[0]);
expect(onClick).toHaveBeenCalledTimes(1);
expect(onChange).not.toHaveBeenCalled();
});
it('disabled suppresses switch', () => {
const onChange = vi.fn();
const onClick = vi.fn();
render(() => (
<SettingRow
id="test-combo"
variant="switch-link"
title="Title"
checked={false}
disabled={true}
onChange={onChange}
onClick={onClick}
/>
));
const rows = screen.getAllByRole('button');
fireEvent.click(rows[0]);
expect(onChange).not.toHaveBeenCalled();
expect(onClick).not.toHaveBeenCalled();
});
});
describe('children slot', () => {
it('renders children when provided', () => {
render(() => (
<SettingRow id="test-children" variant="switch" title="Title">
<div data-testid="child-content">Child Content</div>
</SettingRow>
));
expect(screen.getByTestId('child-content')).toBeDefined();
expect(screen.getByText('Child Content')).toBeDefined();
});
});
});

View file

@ -17,7 +17,7 @@
--default-main-text: var(--gray-80);
--disabled-main-text: var(--gray-50);
/* Description text */
--default-description-text: var(--gray-60);
--default-description-text: var(--gray-70);
/* Forms */
--default-labels: var(--gray-70);
/* Input */

View file

@ -0,0 +1,33 @@
.configDialog {
:global(.rc-dialog-wrap) {
padding: 0;
}
}
.body {
max-height: 600px;
overflow-y: auto;
border: none;
padding: 0 16px;
@media (max-width: 1023px) {
max-height: 70vh;
}
}
.footer {
display: flex;
gap: 8px;
padding: 16px;
}
.saveButton {
width: auto;
max-width: none;
min-width: 200px;
}
.description {
padding: 8px 16px;
color: var(--default-description-text);
}

View file

@ -0,0 +1,52 @@
import { type JSX } from 'solid-js';
import cn from 'clsx';
import { Dialog } from 'panel/common/ui/Dialog';
import { Button } from 'panel/common/ui/Button';
import intl from 'panel/common/intl';
import s from './ConfigDialog.module.pcss';
type Props = {
open: boolean;
title: string;
onClose: () => void;
onSubmit: () => void;
processing?: boolean;
submitDisabled?: boolean;
class?: string;
children?: JSX.Element;
footer?: JSX.Element;
description?: string;
};
export const ConfigDialog = (props: Props) => {
const isDisabled = () => !!props.processing || !!props.submitDisabled;
return (
<Dialog
visible={props.open}
onClose={props.onClose}
title={props.title}
wrapClass={cn('rc-dialog-update', s.configDialog, props.class)}
>
{props.description && <div class={s.description}>{props.description}</div>}
<fieldset disabled={!!props.processing} class={s.body}>
{props.children}
</fieldset>
<div class={s.footer}>
{props.footer}
<Button
variant="primary"
class={s.saveButton}
disabled={isDisabled()}
data-testid="config-dialog-save"
onClick={props.onSubmit}
>
{intl.getMessage('save')}
</Button>
</div>
</Dialog>
);
};

View file

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

View file

@ -0,0 +1,122 @@
.switch {
width: 100%;
padding: 8px 16px;
transition: background-color var(--t2);
border-radius: 8px;
outline: none;
&:hover,
&:focus {
background-color: var(--page-background-additional);
}
}
.switchDisabled {
cursor: default;
&:hover,
&:focus {
background-color: transparent;
}
.title,
.desc,
.value {
color: var(--disabled-main-text);
}
.row {
cursor: default;
}
}
.row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
cursor: pointer;
&.rowCenter {
align-items: center;
}
}
.divider {
margin: 8px 8px 8px auto;
width: 1px;
align-self: stretch;
background-color: var(--default-item-divider);
flex-shrink: 0;
}
.text {
padding: 8px 0;
}
.title {
margin-bottom: 4px;
font-weight: var(--weight-semi-bold);
color: var(--default-main-text);
}
.titleDisabled {
color: var(--disabled-main-text);
}
.desc {
color: var(--default-description-text);
}
.descDisabled {
color: var(--disabled-main-text);
}
.value {
margin-top: 8px;
font-weight: var(--weight-semi-bold);
color: var(--default-description-text);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.valueDisabled {
color: var(--disabled-main-text);
}
.input {
padding: 8px 0;
display: flex;
align-items: flex-start;
gap: 12px;
align-self: stretch;
}
.link {
display: flex;
align-items: center;
gap: 4px;
color: var(--default-gray-icons);
cursor: pointer;
white-space: nowrap;
border: none;
background: none;
padding: 0;
font-size: inherit;
&:disabled {
cursor: default;
opacity: 0.5;
}
}
.arrow {
color: var(--default-description-text);
}
.content {
padding: 8px 0;
}

View file

@ -0,0 +1,169 @@
import { type JSX, Show, createMemo } from 'solid-js';
import cn from 'clsx';
import { Switch } from 'panel/common/controls/Switch';
import { Icon } from 'panel/common/ui/Icon';
import s from './SettingRow.module.pcss';
import theme from 'panel/lib/theme';
type SettingRowVariant = 'switch' | 'link' | 'switch-link';
type Props = {
id: string;
title: string;
description?: string | JSX.Element;
value?: string;
variant: SettingRowVariant;
checked?: boolean;
disabled?: boolean;
titleClass?: string;
onChange?: (checked: boolean) => void;
onClick?: () => void;
class?: string;
children?: JSX.Element;
divider?: boolean;
align?: 'top' | 'center';
};
export const SettingRow = (props: Props) => {
let inputRef: HTMLInputElement | undefined;
const isSwitch = createMemo(() => props.variant === 'switch');
const isLink = createMemo(() => props.variant === 'link');
const isSwitchLink = createMemo(() => props.variant === 'switch-link');
const handleRowClick = (e?: MouseEvent) => {
if (props.disabled) {
return;
}
// Skip programmatic click if the user already clicked the switch/label
// — the native label behaviour already toggles it.
if (e) {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.closest('label')) {
return;
}
}
if (isSwitch()) {
inputRef?.click();
} else if (isLink() || isSwitchLink()) {
props.onClick?.();
}
};
const handleSwitchChange = (e: Event) => {
e.stopPropagation();
if (props.disabled) {
return;
}
const target = e.target as HTMLInputElement;
props.onChange?.(target.checked);
};
const handleLinkClick = (e: Event) => {
e.stopPropagation();
if (props.disabled) {
return;
}
props.onClick?.();
};
const handleInputClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const isSwitchClick = target.tagName === 'INPUT' || !!target.closest('label');
// Native label click already toggled the switch — don't double-fire.
if (isSwitchClick) {
return;
}
if ((isSwitch() || isSwitchLink()) && !props.disabled) {
e.stopPropagation();
inputRef?.click();
}
};
const isSwitchVariant = () => isSwitch() || isSwitchLink();
const isLinkVariant = () => isLink();
return (
<div
class={cn(s.switch, props.class, {
[s.switchDisabled]: props.disabled,
})}
role="button"
tabIndex={props.disabled ? -1 : 0}
onClick={handleRowClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRowClick();
}
}}
>
<div
class={cn(s.row, {
[s.rowTop]: props.align === 'top',
[s.rowCenter]: props.align === 'center',
})}
>
<div class={s.text}>
<div
class={cn(s.title, props.titleClass, {
[s.titleDisabled]: props.disabled,
})}
>
{props.title}
</div>
<Show when={props.description}>
<div
class={cn(s.desc, theme.text.t3, { [s.descDisabled]: props.disabled })}
>
{props.description}
</div>
</Show>
<Show when={props.value}>
<div
class={cn(s.value, theme.text.t3, {
[s.valueDisabled]: props.disabled,
})}
>
{props.value}
</div>
</Show>
</div>
<Show when={isSwitchLink() && props.divider}>
<div class={s.divider} />
</Show>
<div class={s.input} onClick={handleInputClick}>
<Show when={isSwitchVariant()}>
<Switch
id={props.id}
checked={!!props.checked}
disabled={!!props.disabled}
onChange={handleSwitchChange}
ref={(el: HTMLInputElement) => {
inputRef = el;
}}
/>
</Show>
<Show when={isLinkVariant()}>
<button
type="button"
class={s.link}
disabled={!!props.disabled}
onClick={handleLinkClick}
>
<Icon icon="arrow" class={s.arrow} />
</button>
</Show>
</div>
</div>
<Show when={props.children}>
<div class={s.content}>{props.children}</div>
</Show>
</div>
);
};

View file

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

View file

@ -9,7 +9,7 @@ import s from './styles.module.pcss';
type Option<T> = { text: string; value: T };
type Props<T = string | number | boolean> = {
title: string;
title?: string;
description?: string;
disabled?: boolean;
value: T;
@ -22,16 +22,20 @@ type Props<T = string | number | boolean> = {
export const RadioGroup = <T extends number | string | boolean>(props: Props<T>) => {
return (
<div class={cn(s.switch, props.class)}>
<div class={s.row}>
<div class={s.text}>
<div class={cn(s.title, theme.text.t2, theme.text.semibold)}>{props.title}</div>
<Show when={props.description}>
<div class={cn(s.desc, theme.text.t3)}>{props.description}</div>
</Show>
<div class={cn(s.radio, props.class)}>
<Show when={props.title}>
<div class={s.row}>
<div class={s.text}>
<div class={cn(s.title, theme.text.t2, theme.text.semibold)}>
{props.title}
</div>
<Show when={props.description}>
<div class={cn(s.desc, theme.text.t3)}>{props.description}</div>
</Show>
</div>
<div class={s.input} />
</div>
<div class={s.input} />
</div>
</Show>
<div class={s.content}>
<Radio

View file

@ -29,7 +29,7 @@ export const SwitchGroup = (props: Props) => {
};
return (
<div class={cn(s.switch, props.disabled && s.switchDisabled, props.class)}>
<div class={cn(s.switch, { [s.switchDisabled]: props.disabled }, props.class)}>
<div class={s.row} onClick={handleRowClick}>
<div class={s.text}>
<div

View file

@ -17,6 +17,10 @@
}
}
.radio {
width: 100%;
}
.row {
display: flex;
align-items: flex-start;

View file

@ -5,7 +5,7 @@ import theme from 'panel/lib/theme';
import { RoutePath } from 'panel/components/Routes/Paths';
import { Link } from 'panel/common/ui/Link';
import { setFiltersConfig } from 'panel/stores/filtering';
import { SwitchGroup } from 'panel/common/ui/SettingsGroup';
import { SettingRow } from 'panel/common/ui/SettingRow';
export type FormValues = {
enabled: boolean;
@ -26,43 +26,41 @@ export const FiltersConfig = (props: Props) => {
});
return (
<div>
<SwitchGroup
title={intl.getMessage('settings_filter_requests')}
description={intl.getMessage('settings_filter_requests_desc', {
a: (text: string) => (
<Link
to={RoutePath.DnsBlocklists}
class={theme.link.link}
onClick={(e: Event) => e.stopPropagation()}
>
{text}
</Link>
),
b: (text: string) => (
<Link
to={RoutePath.DnsAllowlists}
class={theme.link.link}
onClick={(e: Event) => e.stopPropagation()}
>
{text}
</Link>
),
c: (text: string) => (
<Link
to={RoutePath.UserRules}
class={theme.link.link}
onClick={(e: Event) => e.stopPropagation()}
>
{text}
</Link>
),
})}
disabled={props.processing}
onChange={(e: Event) => setEnabled((e.target as HTMLInputElement).checked)}
id="filters_enabled"
checked={enabled()}
/>
</div>
<SettingRow
variant="switch"
id="filtering_enabled"
title={intl.getMessage('settings_filter_requests')}
description={intl.getMessage('settings_filter_requests_desc', {
a: (text: string) => (
<Link
to={RoutePath.DnsBlocklists}
class={theme.link.link}
onClick={(e: Event) => e.stopPropagation()}
>
{text}
</Link>
),
b: (text: string) => (
<Link
to={RoutePath.DnsAllowlists}
class={theme.link.link}
onClick={(e: Event) => e.stopPropagation()}
>
{text}
</Link>
),
c: (text: string) => (
<Link
to={RoutePath.UserRules}
class={theme.link.link}
onClick={(e: Event) => e.stopPropagation()}
>
{text}
</Link>
),
})}
checked={enabled()}
onChange={(v) => setEnabled(v)}
/>
);
};

View file

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

View file

@ -1,14 +0,0 @@
.link {
margin-left: auto;
}
.label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.dropdownTitle {
margin-bottom: 16px;
}

View file

@ -0,0 +1,7 @@
.dropdownTitle {
margin-bottom: 8px;
}
.link {
margin-left: auto;
}

View file

@ -1,49 +1,63 @@
import { createSignal, createEffect, on } from 'solid-js';
import cn from 'clsx';
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
import { Textarea } from 'panel/common/controls/Textarea';
import intl from 'panel/common/intl';
import { trimLinesAndRemoveEmpty } from 'panel/helpers/helpers';
import theme from 'panel/lib/theme';
import { SwitchGroup } from 'panel/common/ui/SettingsGroup';
import { FaqTooltip } from 'panel/common/ui/FaqTooltip';
import s from './styles.module.pcss';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { trimLinesAndRemoveEmpty } from 'panel/helpers/helpers';
import s from './IgnoredDomainsModal.module.pcss';
type Props = {
ignoredValue: string;
onIgnoredChange: (value: string) => void;
open: boolean;
title: string;
ignored: string[];
processing: boolean;
ignoreEnabled: boolean;
onIgnoreEnabledChange: (checked: boolean) => void;
switchId: string;
textareaId: string;
description: string;
error?: string;
onClose: () => void;
onSave: (ignored: string[]) => void;
};
export const IgnoredDomains = (props: Props) => {
export const IgnoredDomainsModal = (props: Props) => {
const [value, setValue] = createSignal('');
createEffect(
on(
() => props.open,
(open) => {
if (!open) return;
setValue(props.ignored.join('\n'));
},
),
);
const handleSave = () => {
const trimmed = trimLinesAndRemoveEmpty(value());
const ignoredArray = trimmed.split('\n').filter(Boolean);
props.onSave(ignoredArray);
};
return (
<SwitchGroup
id={props.switchId}
title={intl.getMessage('ignore_domains_title')}
description={props.description}
checked={props.ignoreEnabled}
onChange={(e: Event) =>
props.onIgnoreEnabledChange((e.target as HTMLInputElement).checked)
}
disabled={props.processing}
<ConfigDialog
open={props.open}
title={props.title}
onClose={props.onClose}
onSubmit={handleSave}
processing={props.processing}
>
<Textarea
id={props.textareaId}
value={props.ignoredValue}
onChange={(e: Event) => {
const { value } = e.target as HTMLTextAreaElement;
props.onIgnoredChange(value);
value={value()}
onChange={(e: Event) => setValue((e.target as HTMLTextAreaElement).value)}
onBlur={(e: Event) => {
const trimmed = trimLinesAndRemoveEmpty(
(e.target as HTMLTextAreaElement).value,
);
setValue(trimmed);
}}
label={
<>
{intl.getMessage('settings_domain_names')}
<FaqTooltip
text={
<>
@ -61,7 +75,6 @@ export const IgnoredDomains = (props: Props) => {
</>
}
/>
<a
href="https://link.adtidy.org/forward.html?action=dns_kb_filtering_syntax&from=ui&app=home"
target="_blank"
@ -74,15 +87,8 @@ export const IgnoredDomains = (props: Props) => {
}
placeholder={`example.com\n*.example.com\n||example.com^`}
size="large"
disabled={props.processing || !props.ignoreEnabled}
errorMessage={props.error}
onBlur={(e: Event) => {
const trimmed = trimLinesAndRemoveEmpty(
(e.target as HTMLTextAreaElement).value,
);
props.onIgnoredChange(trimmed);
}}
disabled={props.processing}
/>
</SwitchGroup>
</ConfigDialog>
);
};

View file

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

View file

@ -1,48 +1,30 @@
import { createSignal, createMemo } from 'solid-js';
import { createSignal, createEffect } from 'solid-js';
import intl from 'panel/common/intl';
import { Button } from 'panel/common/ui/Button';
import theme from 'panel/lib/theme';
import { QUERY_LOG_INTERVALS_DAYS, RETENTION_CUSTOM } from 'panel/helpers/constants';
import { RadioGroup, SwitchGroup } from 'panel/common/ui/SettingsGroup';
import { IgnoredDomains } from '../IgnoredDomains';
import { RadioGroup } from 'panel/common/ui/SettingsGroup';
import { getIntervalTitle, getDefaultInterval } from '../helpers';
import { RetentionCustomInput } from '../RetentionCustomInput';
export type FormValues = {
enabled: boolean;
anonymize_client_ip: boolean;
interval: number;
customInterval?: number | null;
ignored: string;
ignore_enabled: boolean;
};
type Props = {
initialValues: Partial<FormValues>;
processing: boolean;
processingReset: boolean;
onSubmit: (values: FormValues) => void;
onReset: () => void;
onValuesChange: (values: FormValues) => void;
};
export const Form = (props: Props) => {
const [enabled, setEnabled] = createSignal(props.initialValues.enabled || false);
const [anonymizeClientIp, setAnonymizeClientIp] = createSignal(
props.initialValues.anonymize_client_ip || false,
);
const [intervalValue, setIntervalValue] = createSignal(
getDefaultInterval(props.initialValues.customInterval, props.initialValues.interval),
);
const [customInterval, setCustomInterval] = createSignal<number | null>(
props.initialValues.customInterval ?? null,
);
const [ignored, setIgnored] = createSignal(props.initialValues.ignored || '');
const [ignoreEnabled, setIgnoreEnabled] = createSignal(
props.initialValues.ignore_enabled || true,
);
const [isSubmitting, setIsSubmitting] = createSignal(false);
// Clear customInterval when a standard interval is selected
const handleIntervalChange = (val: number) => {
@ -53,51 +35,18 @@ export const Form = (props: Props) => {
}
};
const disableSubmit = createMemo(
() =>
isSubmitting() ||
props.processing ||
(intervalValue() === RETENTION_CUSTOM && !customInterval()),
);
const handleSubmit = (e: Event) => {
e.preventDefault();
const data: FormValues = {
enabled: enabled(),
anonymize_client_ip: anonymizeClientIp(),
// Notify parent of value changes for dirty tracking
createEffect(() => {
const values: FormValues = {
interval: intervalValue(),
customInterval: customInterval(),
ignored: ignored(),
ignore_enabled: ignoreEnabled(),
};
setIsSubmitting(true);
props.onSubmit(data);
setIsSubmitting(false);
};
props.onValuesChange(values);
});
return (
<form onSubmit={handleSubmit}>
<SwitchGroup
id="logs_enabled"
title={intl.getMessage('settings_log_dns_requests')}
checked={enabled()}
onChange={(e: Event) => setEnabled((e.target as HTMLInputElement).checked)}
disabled={props.processing}
/>
<SwitchGroup
id="logs_anonymize_client_ip"
title={intl.getMessage('settings_anonymize_client_ip')}
description={intl.getMessage('settings_anonymize_client_ip_desc')}
checked={anonymizeClientIp()}
onChange={(e: Event) =>
setAnonymizeClientIp((e.target as HTMLInputElement).checked)
}
disabled={props.processing}
/>
<>
<RadioGroup
title={intl.getMessage('query_log_retention')}
disabled={props.processing}
value={intervalValue()}
onChange={handleIntervalChange}
@ -121,42 +70,6 @@ export const Form = (props: Props) => {
placeholder={intl.getMessage('settings_rotation_placeholder')}
/>
</RadioGroup>
<IgnoredDomains
ignoredValue={ignored()}
onIgnoredChange={setIgnored}
processing={props.processing}
ignoreEnabled={ignoreEnabled()}
onIgnoreEnabledChange={setIgnoreEnabled}
switchId="logs_config_ignored_enabled"
textareaId="logs_config_ignored"
description={intl.getMessage('ignore_domains_desc_log')}
/>
<div class={theme.form.buttonGroup}>
<Button
type="submit"
id="logs_config_save"
variant="primary"
size="small"
disabled={disableSubmit()}
class={theme.form.button}
>
{intl.getMessage('save')}
</Button>
<Button
type="button"
id="logs_config_clear"
variant="secondary"
size="small"
onClick={props.onReset}
disabled={props.processingReset}
class={theme.form.button}
>
{intl.getMessage('clear_query_log')}
</Button>
</div>
</form>
</>
);
};

View file

@ -1,89 +1,113 @@
import { createSignal, Show } from 'solid-js';
import { createSignal, createEffect, Show, on } from 'solid-js';
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
import intl from 'panel/common/intl';
import { HOUR } from 'panel/helpers/constants';
import { formatIntervalText } from 'panel/components/Settings/helpers';
import { clearLogs, setLogsConfig } from 'panel/stores/queryLogs';
import { Form, type FormValues } from './Form';
import { formatIntervalText, resolveInterval } from 'panel/components/Settings/helpers';
import { setLogsConfig, queryLogsState } from 'panel/stores/queryLogs';
import { Form, FormValues } from './Form';
import { addSuccessToast } from 'panel/stores/toasts';
export type LogsConfigPayload = {
enabled: boolean;
anonymize_client_ip: boolean;
ignore_enabled: boolean;
ignored: string[];
interval: number;
};
type Props = {
interval: number;
customInterval?: number;
enabled: boolean;
anonymize_client_ip: boolean;
processing: boolean;
ignored: string[];
processingClear: boolean;
modalOpen: boolean;
onModalClose: () => void;
};
export const LogsConfig = (props: Props) => {
const [openConfirmDialog, setOpenConfirmDialog] = createSignal(false);
const [formValues, setFormValues] = createSignal<FormValues>({
interval: 0,
customInterval: null,
});
const [confirmConfig, setConfirmConfig] = createSignal<LogsConfigPayload | null>(null);
const handleClear = () => {
setOpenConfirmDialog(true);
createEffect(
on(
() => props.modalOpen,
(open) => {
if (!open) return;
setFormValues({
interval: props.interval,
customInterval: props.customInterval,
});
},
),
);
const handleFormChange = (values: FormValues) => {
setFormValues(values);
};
const handleClose = () => {
setOpenConfirmDialog(false);
};
const handleClearConfirm = () => {
clearLogs();
handleClose();
};
const handleFormSubmit = (values: FormValues) => {
const { interval, customInterval, ...rest } = values;
const newInterval = customInterval ? customInterval * HOUR : interval;
const data: LogsConfigPayload = {
...rest,
ignored: values.ignored ? values.ignored.split('\n') : [],
interval: newInterval,
};
const handleSave = () => {
const values = formValues();
const newInterval = resolveInterval(values.interval, values.customInterval);
// If decreasing retention, show confirmation
if (newInterval < props.interval) {
setConfirmConfig(data);
return;
setConfirmConfig({ interval: newInterval });
} else {
// Save with all required fields from current state
setLogsConfig({
enabled: queryLogsState.enabled,
anonymize_client_ip: queryLogsState.anonymize_client_ip,
ignored: queryLogsState.ignored,
ignored_enabled: queryLogsState.ignored_enabled,
interval: newInterval,
customInterval: values.customInterval,
});
addSuccessToast(intl.getMessage('changes_saved_success'));
props.onModalClose();
}
};
setLogsConfig(data);
const handleConfirmDecrease = () => {
const config = confirmConfig();
if (config) {
setLogsConfig({
enabled: queryLogsState.enabled,
anonymize_client_ip: queryLogsState.anonymize_client_ip,
ignored: queryLogsState.ignored,
ignored_enabled: queryLogsState.ignored_enabled,
interval: config.interval,
customInterval: formValues().customInterval,
});
addSuccessToast(intl.getMessage('changes_saved_success'));
props.onModalClose();
}
setConfirmConfig(null);
};
return (
<>
<Form
initialValues={{
enabled: props.enabled,
interval: props.interval,
customInterval: props.customInterval,
anonymize_client_ip: props.anonymize_client_ip,
ignored: props.ignored?.join('\n'),
}}
<ConfigDialog
open={props.modalOpen}
title={intl.getMessage('query_log_retention')}
onClose={props.onModalClose}
onSubmit={handleSave}
processing={props.processing}
processingReset={props.processingClear}
onSubmit={handleFormSubmit}
onReset={handleClear}
/>
>
<Form
initialValues={{
interval: props.interval,
customInterval: props.customInterval,
}}
processing={props.processing}
onValuesChange={handleFormChange}
/>
</ConfigDialog>
<Show when={confirmConfig()}>
{(config) => (
<ConfirmDialog
onClose={() => setConfirmConfig(null)}
onConfirm={() => {
setLogsConfig(config());
setConfirmConfig(null);
}}
onConfirm={handleConfirmDecrease}
buttonText={intl.getMessage('settings_yes_decrease')}
cancelText={intl.getMessage('cancel')}
title={intl.getMessage('settings_confirm_decrease_log_rotation_interval')}
@ -97,17 +121,6 @@ export const LogsConfig = (props: Props) => {
/>
)}
</Show>
<Show when={openConfirmDialog()}>
<ConfirmDialog
onClose={handleClose}
onConfirm={handleClearConfirm}
buttonText={intl.getMessage('settings_yes_clear')}
cancelText={intl.getMessage('cancel')}
title={intl.getMessage('settings_confirm_clear_query_log')}
text={intl.getMessage('settings_confirm_clear_query_log_desc')}
buttonVariant="danger"
/>
</Show>
</>
);
};

View file

@ -0,0 +1,6 @@
.providersGrid {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 0;
}

View file

@ -0,0 +1,71 @@
import { createEffect, For, on } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Checkbox } from 'panel/common/controls/Checkbox';
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
import { SAFE_SEARCH_PROVIDERS } from 'panel/helpers/constants';
import { getSafeSearchProviderTitle } from '../helpers';
import intl from 'panel/common/intl';
import s from './SafeSearchModal.module.pcss';
type Props = {
open: boolean;
onClose: () => void;
providers: Record<string, boolean>;
enabled: boolean;
processing: boolean;
onSave: (providers: Record<string, boolean>) => void;
};
export const SafeSearchModal = (props: Props) => {
const [selected, setSelected] = createStore<Record<string, boolean>>({});
createEffect(
on(
() => props.open,
(open) => {
if (!open) return;
const entries = Object.keys(SAFE_SEARCH_PROVIDERS).map((key) => [
key,
props.providers[key] ?? false,
]);
setSelected(Object.fromEntries(entries));
},
),
);
const handleSave = () => {
const result: Record<string, boolean> = {};
Object.keys(SAFE_SEARCH_PROVIDERS).forEach((key) => {
result[key] = selected[key] ?? false;
});
props.onSave(result);
};
return (
<ConfigDialog
open={props.open}
title={intl.getMessage('settings_safe_search')}
onClose={props.onClose}
onSubmit={handleSave}
processing={props.processing}
description={intl.getMessage('settings_safe_search_desc')}
>
<div class={s.providersGrid}>
<For each={Object.keys(SAFE_SEARCH_PROVIDERS)}>
{(key) => (
<Checkbox
id={`safesearch-${key}`}
checked={selected[key] ?? false}
disabled={props.processing}
onChange={() => setSelected(key, !selected[key])}
>
{getSafeSearchProviderTitle(key)}
</Checkbox>
)}
</For>
</div>
</ConfigDialog>
);
};

View file

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

View file

@ -0,0 +1,42 @@
.linkRow {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
cursor: pointer;
}
.linkValue {
font-weight: var(--weight-semi-bold);
color: var(--default-main-text);
}
.linkArrow {
color: var(--default-description-text);
flex-shrink: 0;
}
.clearButton {
width: auto;
min-width: 200px;
}
.section {
padding-top: 24px;
}
.actionRow {
padding: 8px 16px;
}
.statsTitle {
margin-bottom: 8px;
}
.title {
padding-bottom: 16px;
}
.sectionTitle {
line-height: 32px;
}

View file

@ -1,4 +1,5 @@
import { createMemo, createEffect, onMount, For, Show, untrack } from 'solid-js';
import { createMemo, createEffect, onMount, Show, untrack } from 'solid-js';
import { createSignal } from 'solid-js';
import cn from 'clsx';
import { SCROLL_QUERY_KEY } from 'panel/components/Routes/Paths';
@ -6,32 +7,26 @@ import { SCROLL_QUERY_KEY } from 'panel/components/Routes/Paths';
import { useSearchParams } from '@solidjs/router';
import intl from 'panel/common/intl';
import { Checkbox } from 'panel/common/controls/Checkbox';
import { Button } from 'panel/common/ui/Button';
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
import theme from 'panel/lib/theme';
import { PageLoader } from 'panel/common/ui/Loader';
import { initSettings, toggleSetting, settingsState } from 'panel/stores/settings';
import { getStatsConfig, statsState } from 'panel/stores/stats';
import { getLogsConfig, queryLogsState } from 'panel/stores/queryLogs';
import { getStatsConfig, setStatsConfig, resetStats, statsState } from 'panel/stores/stats';
import { getLogsConfig, setLogsConfig, clearLogs, queryLogsState } from 'panel/stores/queryLogs';
import { getFilteringStatus, filteringState } from 'panel/stores/filtering';
import { SwitchGroup } from 'panel/common/ui/SettingsGroup';
import { SAFE_SEARCH_PROVIDERS } from 'panel/helpers/constants';
import { addSuccessToast } from 'panel/stores/toasts';
import { StatsConfig } from './StatsConfig/StatsConfig';
import { SettingRow } from 'panel/common/ui/SettingRow';
import { StatsConfig } from './StatsConfig';
import { LogsConfig } from './LogsConfig';
import { FiltersConfig } from './FiltersConfig';
import { getSafeSearchProviderTitle } from './helpers';
import { SafeSearchModal } from './SafeSearchModal';
import { IgnoredDomainsModal } from './IgnoredDomainsModal';
import { getRetentionSummary, getSafeSearchProviderTitle } from './helpers';
const SETTINGS = {
safebrowsing: {
enabled: false,
title: intl.getMessage('settings_browsing_security'),
subtitle: intl.getMessage('settings_browsing_security_desc'),
},
parental: {
enabled: false,
title: intl.getMessage('settings_parental_control'),
subtitle: intl.getMessage('settings_parental_control_desc'),
},
};
import s from './Settings.module.pcss';
export const Settings = () => {
onMount(() => {
@ -41,42 +36,92 @@ export const Settings = () => {
getLogsConfig();
});
const handleSettingToggle = (key: keyof typeof SETTINGS) => (e: Event) =>
toggleSetting(key, !(e.target as HTMLInputElement).checked);
const settingsKeys = Object.keys(SETTINGS) as Array<keyof typeof SETTINGS>;
const [logsModalOpen, setLogsModalOpen] = createSignal(false);
const [statsModalOpen, setStatsModalOpen] = createSignal(false);
const [safesearchProvidersOpen, setSafesearchProvidersOpen] = createSignal(false);
const [showClearLogsConfirm, setShowClearLogsConfirm] = createSignal(false);
const [showClearStatsConfirm, setShowClearStatsConfirm] = createSignal(false);
const [logsIgnoredModalOpen, setLogsIgnoredModalOpen] = createSignal(false);
const [statsIgnoredModalOpen, setStatsIgnoredModalOpen] = createSignal(false);
const [safesearchProcessing, setSafesearchProcessing] = createSignal(false);
const safesearch = createMemo(() => settingsState.settingsList?.safesearch);
const safesearchEnabled = createMemo(() => safesearch()?.enabled ?? false);
const safesearchProviders = createMemo(() => {
const logsRetentionSummary = createMemo(() => getRetentionSummary(queryLogsState.interval));
const statsRetentionSummary = createMemo(() => getRetentionSummary(statsState.interval));
const safesearchSummary = createMemo(() => {
const ss = safesearch();
if (!ss) return [];
const { enabled, ...providers } = ss;
void enabled;
return Object.entries(providers).map(([key, value]) => ({ key, value: value as boolean }));
if (!ss) return '';
const selected = Object.keys(SAFE_SEARCH_PROVIDERS)
.filter((key) => ss[key])
.map(getSafeSearchProviderTitle);
return selected.join(', ');
});
const onSafeSearchEnabledChange = (e: Event) => {
const ss = untrack(safesearch);
if (!ss) return;
const payload = { ...ss, enabled: (e.target as HTMLInputElement).checked };
toggleSetting('safesearch', payload);
const logsIgnoredSummary = createMemo(() => {
const ignored = queryLogsState.ignored;
if (!ignored || ignored.length === 0) return '';
return ignored.join(', ');
});
const statsIgnoredSummary = createMemo(() => {
const ignored = statsState.ignored;
if (!ignored || ignored.length === 0) return '';
return ignored.join(', ');
});
// Handler functions
const handleSafeSearchSave = (newProviders: Record<string, boolean>) => {
const ss = untrack(() => settingsState.settingsList.safesearch);
setSafesearchProcessing(true);
toggleSetting('safesearch', { ...ss, ...newProviders })
.then((result) => {
if (result) {
setSafesearchProvidersOpen(false);
addSuccessToast(intl.getMessage('changes_saved_success'));
}
})
.finally(() => setSafesearchProcessing(false));
};
const onProviderChange = (searchKey: string) => (e: Event) => {
const ss = untrack(safesearch);
if (!ss) return;
const payload = { ...ss, [searchKey]: (e.target as HTMLInputElement).checked };
toggleSetting('safesearch', payload);
const handleLogsIgnoredSave = (ignored: string[]) => {
setLogsConfig({ ...queryLogsState, ignored }).then((result) => {
if (result) {
setLogsIgnoredModalOpen(false);
addSuccessToast(intl.getMessage('changes_saved_success'));
}
});
};
const handleClearLogs = () => {
clearLogs().then(() => {
setShowClearLogsConfirm(false);
});
};
const handleStatsIgnoredSave = (ignored: string[]) => {
setStatsConfig({ ...statsState, ignored }).then((result) => {
if (result) {
setStatsIgnoredModalOpen(false);
addSuccessToast(intl.getMessage('changes_saved_success'));
}
});
};
const handleClearStats = () => {
resetStats().then(() => {
setShowClearStatsConfirm(false);
});
};
const isLoading = createMemo(() => {
const hasCachedData = Object.keys(settingsState.settingsList || {}).length > 0;
return (
!hasCachedData &&
(settingsState.processing ||
statsState.processingGetConfig ||
queryLogsState.processingGetConfig)
settingsState.processing ||
statsState.processingGetConfig ||
queryLogsState.processingGetConfig
);
});
@ -100,7 +145,7 @@ export const Settings = () => {
return (
<div class={theme.layout.container}>
<div class={cn(theme.layout.containerIn, theme.layout.containerIn_one_col)}>
<h1 class={cn(theme.layout.title, theme.title.h4, theme.title.h3_tablet)}>
<h1 class={cn(theme.layout.title, theme.title.h4, theme.title.h3_tablet, s.title)}>
{intl.getMessage('settings_general_short')}
</h1>
@ -109,10 +154,12 @@ export const Settings = () => {
fallback={
<>
<h2
id="filtering"
class={cn(
theme.layout.subtitle,
theme.title.h5,
theme.title.h4_tablet,
s.title,
)}
>
{intl.getMessage('settings_filtering_and_security')}
@ -126,91 +173,233 @@ export const Settings = () => {
processing={filteringState.processingSetConfig}
/>
<For each={settingsKeys}>
{(key) => {
const { title, subtitle } = SETTINGS[key];
const enabled = () =>
Boolean(settingsState.settingsList?.[key]?.enabled);
return (
<div>
<SwitchGroup
title={title}
description={subtitle}
id={String(key)}
checked={enabled()}
onChange={handleSettingToggle(key)}
/>
</div>
);
<SettingRow
variant="switch"
id="safebrowsing"
title={intl.getMessage('settings_browsing_security')}
description={intl.getMessage('settings_browsing_security_desc')}
checked={!!settingsState.settingsList?.safebrowsing?.enabled}
onChange={(v) => toggleSetting('safebrowsing', !v)}
/>
<SettingRow
variant="switch"
id="parental"
title={intl.getMessage('settings_parental_control')}
description={intl.getMessage('settings_parental_control_desc')}
checked={!!settingsState.settingsList?.parental?.enabled}
onChange={(v) => toggleSetting('parental', !v)}
/>
<SettingRow
variant="switch-link"
id="safesearch"
title={intl.getMessage('settings_safe_search')}
description={intl.getMessage('settings_safe_search_desc')}
checked={safesearchEnabled()}
value={safesearchSummary()}
divider
onChange={(v) => {
const ss = untrack(() => settingsState.settingsList.safesearch);
toggleSetting('safesearch', { ...ss, enabled: v });
}}
</For>
onClick={() => setSafesearchProvidersOpen(true)}
/>
<Show when={safesearch()}>
<SwitchGroup
id="safesearch"
title={intl.getMessage('settings_safe_search')}
description={intl.getMessage('settings_safe_search_desc')}
checked={safesearchEnabled()}
onChange={onSafeSearchEnabledChange}
>
<div>
<For each={safesearchProviders()}>
{(provider) => (
<div class={theme.form.checkbox}>
<Checkbox
id={provider.key}
checked={provider.value}
disabled={!safesearchEnabled()}
onChange={onProviderChange(provider.key)}
>
{getSafeSearchProviderTitle(provider.key)}
</Checkbox>
</div>
)}
</For>
</div>
</SwitchGroup>
</Show>
<SafeSearchModal
open={safesearchProvidersOpen()}
onClose={() => setSafesearchProvidersOpen(false)}
providers={settingsState.settingsList.safesearch}
enabled={safesearchEnabled()}
processing={safesearchProcessing()}
onSave={handleSafeSearchSave}
/>
<h2
class={cn(
theme.layout.subtitle,
theme.title.h5,
theme.title.h4_tablet,
)}
>
{intl.getMessage('query_log')}
</h2>
<div class={s.section} id="query-log">
<SettingRow
variant="switch"
id="querylog_enabled"
title={intl.getMessage('query_log')}
titleClass={cn(
theme.title.h5,
theme.title.h4_tablet,
theme.text.bold,
s.sectionTitle,
)}
align="center"
checked={queryLogsState.enabled}
onChange={(v) =>
setLogsConfig({
...queryLogsState,
enabled: v,
})
}
/>
<SettingRow
variant="switch"
id="querylog_anonymize"
title={intl.getMessage('settings_anonymize_client_ip')}
description={intl.getMessage(
'settings_anonymize_client_ip_desc',
)}
checked={queryLogsState.anonymize_client_ip}
disabled={!queryLogsState.enabled}
onChange={(v) =>
setLogsConfig({
...queryLogsState,
anonymize_client_ip: v,
})
}
/>
<SettingRow
variant="link"
id="querylog_retention"
title={intl.getMessage('query_log_retention')}
value={logsRetentionSummary()}
disabled={!queryLogsState.enabled}
onClick={() => setLogsModalOpen(true)}
/>
<SettingRow
variant="switch-link"
id="querylog_ignored"
title={intl.getMessage('ignore_domains_title')}
description={intl.getMessage('ignore_domains_desc_log')}
checked={queryLogsState.ignored_enabled}
value={logsIgnoredSummary()}
divider
disabled={!queryLogsState.enabled}
onChange={(v) =>
setLogsConfig({ ...queryLogsState, ignored_enabled: v })
}
onClick={() => setLogsIgnoredModalOpen(true)}
/>
<div class={s.actionRow}>
<Button
variant="secondary-danger"
class={s.clearButton}
onClick={() => setShowClearLogsConfirm(true)}
compact
>
{intl.getMessage('clear_query_log')}
</Button>
</div>
</div>
<LogsConfig
enabled={queryLogsState.enabled}
ignored={queryLogsState.ignored}
interval={queryLogsState.interval}
customInterval={queryLogsState.customInterval}
anonymize_client_ip={queryLogsState.anonymize_client_ip}
processing={queryLogsState.processingSetConfig}
processingClear={queryLogsState.processingClear}
modalOpen={logsModalOpen()}
onModalClose={() => setLogsModalOpen(false)}
/>
<h2
id="stats_config"
class={cn(
theme.layout.subtitle,
theme.title.h5,
theme.title.h4_tablet,
)}
>
{intl.getMessage('settings_statistics')}
</h2>
<StatsConfig
interval={statsState.interval}
customInterval={statsState.customInterval}
ignored={statsState.ignored}
enabled={statsState.enabled}
processing={statsState.processingSetConfig}
processingReset={statsState.processingReset}
<IgnoredDomainsModal
open={logsIgnoredModalOpen()}
title={intl.getMessage('ignore_domains_title')}
ignored={queryLogsState.ignored}
processing={queryLogsState.processingSetConfig}
onClose={() => setLogsIgnoredModalOpen(false)}
onSave={handleLogsIgnoredSave}
/>
<Show when={showClearLogsConfirm()}>
<ConfirmDialog
title={intl.getMessage('settings_confirm_clear_query_log')}
text={intl.getMessage('settings_confirm_clear_query_log_desc')}
buttonText={intl.getMessage('settings_yes_clear')}
cancelText={intl.getMessage('cancel')}
buttonVariant="danger"
onClose={() => setShowClearLogsConfirm(false)}
onConfirm={handleClearLogs}
/>
</Show>
<div class={s.section} id="statistics">
<SettingRow
variant="switch"
id="stats_enabled"
title={intl.getMessage('settings_statistics')}
description={intl.getMessage('settings_statistics_desc')}
titleClass={cn(
theme.title.h5,
theme.title.h4_tablet,
theme.text.bold,
s.statsTitle,
)}
checked={statsState.enabled}
onChange={(v) => setStatsConfig({ ...statsState, enabled: v })}
/>
<SettingRow
variant="link"
id="stats_retention"
title={intl.getMessage('settings_statistics_retention')}
value={statsRetentionSummary()}
disabled={!statsState.enabled}
onClick={() => setStatsModalOpen(true)}
/>
<SettingRow
variant="switch-link"
id="stats_ignored"
title={intl.getMessage('ignore_domains_title')}
description={intl.getMessage('ignore_domains_desc_stats')}
checked={statsState.ignored_enabled}
value={statsIgnoredSummary()}
divider
disabled={!statsState.enabled}
onChange={(v) =>
setStatsConfig({ ...statsState, ignored_enabled: v })
}
onClick={() => setStatsIgnoredModalOpen(true)}
/>
<div class={s.actionRow}>
<Button
variant="secondary-danger"
class={s.clearButton}
onClick={() => setShowClearStatsConfirm(true)}
compact
>
{intl.getMessage('settings_statistics_clear')}
</Button>
</div>
<StatsConfig
interval={statsState.interval}
customInterval={statsState.customInterval}
processing={statsState.processingSetConfig}
modalOpen={statsModalOpen()}
onModalClose={() => setStatsModalOpen(false)}
/>
<IgnoredDomainsModal
open={statsIgnoredModalOpen()}
title={intl.getMessage('ignore_domains_title')}
ignored={statsState.ignored}
processing={statsState.processingSetConfig}
onClose={() => setStatsIgnoredModalOpen(false)}
onSave={handleStatsIgnoredSave}
/>
<Show when={showClearStatsConfirm()}>
<ConfirmDialog
title={intl.getMessage('settings_confirm_clear_statistics')}
text={intl.getMessage(
'settings_confirm_clear_statistics_desc',
)}
buttonText={intl.getMessage('settings_yes_clear')}
cancelText={intl.getMessage('cancel')}
buttonVariant="danger"
onClose={() => setShowClearStatsConfirm(false)}
onConfirm={handleClearStats}
/>
</Show>
</div>
</>
}
>

View file

@ -1,44 +1,30 @@
import { createSignal, createMemo } from 'solid-js';
import { createSignal, createEffect } from 'solid-js';
import { Button } from 'panel/common/ui/Button';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { RadioGroup, SwitchGroup } from 'panel/common/ui/SettingsGroup';
import { RadioGroup } from 'panel/common/ui/SettingsGroup';
import { getIntervalTitle, getDefaultInterval } from '../helpers';
import { STATS_INTERVALS_DAYS, RETENTION_CUSTOM } from 'panel/helpers/constants';
import { IgnoredDomains } from '../IgnoredDomains';
import { RetentionCustomInput } from '../RetentionCustomInput';
export type FormValues = {
enabled: boolean;
interval: number;
customInterval?: number | null;
ignored: string;
ignore_enabled: boolean;
};
type Props = {
initialValues: FormValues;
initialValues: Partial<FormValues>;
processing: boolean;
processingReset: boolean;
onSubmit: (values: FormValues) => void;
onReset: () => void;
onValuesChange: (values: FormValues) => void;
};
export const Form = (props: Props) => {
const [enabled, setEnabled] = createSignal(props.initialValues.enabled || false);
const [intervalValue, setIntervalValue] = createSignal(
getDefaultInterval(props.initialValues.customInterval, props.initialValues.interval),
);
const [customInterval, setCustomInterval] = createSignal<number | null>(
props.initialValues.customInterval ?? null,
);
const [ignored, setIgnored] = createSignal(props.initialValues.ignored || '');
const [ignoreEnabled, setIgnoreEnabled] = createSignal(
props.initialValues.ignore_enabled || true,
);
const [isSubmitting, setIsSubmitting] = createSignal(false);
// Clear customInterval when a standard interval is selected
const handleIntervalChange = (val: number) => {
@ -49,40 +35,18 @@ export const Form = (props: Props) => {
}
};
const disableSubmit = createMemo(
() =>
isSubmitting() ||
props.processing ||
(intervalValue() === RETENTION_CUSTOM && !customInterval()),
);
const handleSubmit = (e: Event) => {
e.preventDefault();
const data: FormValues = {
enabled: enabled(),
// Notify parent of value changes for dirty tracking
createEffect(() => {
const values: FormValues = {
interval: intervalValue(),
customInterval: customInterval(),
ignored: ignored(),
ignore_enabled: ignoreEnabled(),
};
setIsSubmitting(true);
props.onSubmit(data);
setIsSubmitting(false);
};
props.onValuesChange(values);
});
return (
<form onSubmit={handleSubmit}>
<SwitchGroup
checked={enabled()}
onChange={(e: Event) => setEnabled((e.target as HTMLInputElement).checked)}
id="stats_config_enabled"
title={intl.getMessage('settings_statistics')}
description={intl.getMessage('settings_statistics_desc')}
disabled={props.processing}
/>
<>
<RadioGroup
title={intl.getMessage('settings_statistics_retention')}
disabled={props.processing}
value={intervalValue()}
onChange={handleIntervalChange}
@ -106,42 +70,6 @@ export const Form = (props: Props) => {
placeholder={intl.getMessage('settings_rotation_placeholder')}
/>
</RadioGroup>
<IgnoredDomains
ignoredValue={ignored()}
onIgnoredChange={setIgnored}
processing={props.processing}
ignoreEnabled={ignoreEnabled()}
onIgnoreEnabledChange={setIgnoreEnabled}
switchId="stats_config_ignored_enabled"
textareaId="stats_config_ignored"
description={intl.getMessage('ignore_domains_desc_stats')}
/>
<div class={theme.form.buttonGroup}>
<Button
type="submit"
id="stats_config_save"
variant="primary"
size="small"
disabled={disableSubmit()}
class={theme.form.button}
>
{intl.getMessage('save')}
</Button>
<Button
type="button"
id="stats_config_clear"
onClick={props.onReset}
variant="secondary"
size="small"
disabled={props.processingReset}
class={theme.form.button}
>
{intl.getMessage('settings_statistics_clear')}
</Button>
</div>
</form>
</>
);
};

View file

@ -1,92 +1,112 @@
import { createSignal, Show } from 'solid-js';
import { createSignal, createEffect, Show, on } from 'solid-js';
import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
import intl from 'panel/common/intl';
import { HOUR } from 'panel/helpers/constants';
import { formatIntervalText } from 'panel/components/Settings/helpers';
import { formatIntervalText, resolveInterval } from 'panel/components/Settings/helpers';
import { resetStats, setStatsConfig } from 'panel/stores/stats';
import { Form, type FormValues } from './Form';
import { setStatsConfig, statsState } from 'panel/stores/stats';
import { Form, FormValues } from './Form';
import { addSuccessToast } from 'panel/stores/toasts';
export type StatsConfigPayload = {
enabled: boolean;
ignored: string[];
interval: number;
};
type Props = {
interval: number;
customInterval?: number;
ignored: string[];
enabled: boolean;
processing: boolean;
processingReset: boolean;
modalOpen: boolean;
onModalClose: () => void;
};
export const StatsConfig = (props: Props) => {
const [openClearDialog, setOpenClearDialog] = createSignal(false);
const [formValues, setFormValues] = createSignal<FormValues>({
interval: 0,
customInterval: null,
});
const [confirmConfig, setConfirmConfig] = createSignal<StatsConfigPayload | null>(null);
const handleClear = () => {
setOpenClearDialog(true);
createEffect(
on(
() => props.modalOpen,
(open) => {
if (!open) return;
setFormValues({
interval: props.interval,
customInterval: props.customInterval,
});
},
),
);
const handleFormChange = (values: FormValues) => {
setFormValues(values);
};
const handleClose = () => {
setOpenClearDialog(false);
};
const handleClearConfirm = () => {
resetStats();
handleClose();
};
const handleFormSubmit = (values: FormValues) => {
const { interval, customInterval, enabled, ignored } = values;
let newInterval: number;
if (customInterval) {
newInterval = customInterval >= HOUR ? customInterval : customInterval * HOUR;
} else {
newInterval = interval;
}
const data: StatsConfigPayload = {
enabled,
interval: newInterval,
ignored: ignored ? ignored.split('\n') : [],
};
const handleSave = () => {
const values = formValues();
const newInterval = resolveInterval(values.interval, values.customInterval);
// If decreasing retention, show confirmation
if (newInterval < props.interval) {
setConfirmConfig(data);
return;
setConfirmConfig({ interval: newInterval });
} else {
// Save with all required fields from current state
setStatsConfig({
enabled: statsState.enabled,
ignored: statsState.ignored,
ignored_enabled: statsState.ignored_enabled,
interval: newInterval,
customInterval: values.customInterval,
});
addSuccessToast(intl.getMessage('changes_saved_success'));
props.onModalClose();
}
};
setStatsConfig(data);
const handleConfirmDecrease = () => {
const config = confirmConfig();
if (config) {
setStatsConfig({
enabled: statsState.enabled,
ignored: statsState.ignored,
ignored_enabled: statsState.ignored_enabled,
interval: config.interval,
customInterval: formValues().customInterval,
});
addSuccessToast(intl.getMessage('changes_saved_success'));
props.onModalClose();
}
setConfirmConfig(null);
};
return (
<>
<Form
initialValues={{
interval: props.interval,
customInterval: props.customInterval,
enabled: props.enabled,
ignored: props.ignored.join('\n'),
ignore_enabled: false,
}}
<ConfigDialog
open={props.modalOpen}
title={intl.getMessage('settings_statistics_retention')}
onClose={props.onModalClose}
onSubmit={handleSave}
processing={props.processing}
processingReset={props.processingReset}
onSubmit={handleFormSubmit}
onReset={handleClear}
/>
>
<Form
initialValues={{
interval: props.interval,
customInterval: props.customInterval,
}}
processing={props.processing}
onValuesChange={handleFormChange}
/>
</ConfigDialog>
<Show when={confirmConfig()}>
{(config) => (
<ConfirmDialog
onClose={() => setConfirmConfig(null)}
onConfirm={() => {
setStatsConfig(config());
setConfirmConfig(null);
}}
onConfirm={handleConfirmDecrease}
buttonText={intl.getMessage('settings_yes_decrease')}
cancelText={intl.getMessage('cancel')}
title={intl.getMessage('settings_confirm_decrease_stats_rotation_interval')}
@ -100,17 +120,6 @@ export const StatsConfig = (props: Props) => {
/>
)}
</Show>
<Show when={openClearDialog()}>
<ConfirmDialog
onClose={handleClose}
onConfirm={handleClearConfirm}
buttonText={intl.getMessage('settings_yes_clear')}
cancelText={intl.getMessage('cancel')}
title={intl.getMessage('settings_confirm_clear_statistics')}
text={intl.getMessage('settings_confirm_clear_statistics_desc')}
buttonVariant="danger"
/>
</Show>
</>
);
};

View file

@ -29,6 +29,27 @@ export const getDefaultInterval = (customInterval?: number, interval?: number) =
return interval || DAY;
};
export const resolveInterval = (interval: number, customInterval?: number | null): number => {
if (customInterval) {
return customInterval >= HOUR ? customInterval : customInterval * HOUR;
}
return interval;
};
export const getRetentionSummary = (intervalMs: number) => {
if (intervalMs === 6 * HOUR) {
return intl.getPlural('last_hours', 6);
}
if (intervalMs === DAY) {
return intl.getPlural('last_hours', 24);
}
if (intervalMs % DAY === 0) {
return intl.getPlural('last_days', intervalMs / DAY);
}
return intl.getPlural('last_hours', Math.floor(intervalMs / HOUR));
};
const SAFESEARCH_TITLES = {
bing: 'Bing',
duckduckgo: 'DuckDuckGo',

View file

@ -968,7 +968,7 @@ export const sortIp = (a: string, b: string): number => {
export const getSpecialFilterName = (filterId: any) => {
switch (filterId) {
case SPECIAL_FILTER_ID.CUSTOM_FILTERING_RULES:
return intl.getMessage('custom_filter_rules');
return intl.getMessage('custom_rules');
case SPECIAL_FILTER_ID.SYSTEM_HOSTS:
return intl.getMessage('system_host_files');
case SPECIAL_FILTER_ID.BLOCKED_SERVICES:

View file

@ -32,7 +32,7 @@
&_one_col {
@media (min-width: 1024px) {
max-width: 632px;
max-width: 584px;
}
}
}

View file

@ -30,6 +30,7 @@ type QueryLogsState = {
isEntireLog: boolean;
customInterval: number | null;
ignored: string[];
ignored_enabled: boolean;
};
const initialState: QueryLogsState = {
@ -49,6 +50,7 @@ const initialState: QueryLogsState = {
isEntireLog: false,
customInterval: null,
ignored: [],
ignored_enabled: false,
};
const [state, setState] = createStore<QueryLogsState>(initialState);
@ -203,6 +205,7 @@ export const getLogsConfig = async () => {
? data.interval / HOUR
: null,
ignored: data.ignored || [],
ignored_enabled: data.ignored_enabled ?? false,
processingGetConfig: false,
});
} catch (error) {
@ -211,15 +214,16 @@ export const getLogsConfig = async () => {
}
};
export const setLogsConfig = async (values: any) => {
export const setLogsConfig = async (values: any): Promise<boolean> => {
setState('processingSetConfig', true);
try {
await apiClient.setQueryLogConfig(values);
setState({ ...values, processingSetConfig: false });
addSuccessToast(intl.getMessage('settings_notify_changes_saved'));
return true;
} catch (error) {
addErrorToast({ error });
setState('processingSetConfig', false);
return false;
}
};

View file

@ -41,6 +41,7 @@ type StatsState = {
topUpstreamsAvgTime: { name: string; count: number }[];
topUpstreamsResponses: { name: string; count: number }[];
ignored: string[];
ignored_enabled: boolean;
};
const initialState: StatsState = {
@ -69,6 +70,7 @@ const initialState: StatsState = {
topUpstreamsAvgTime: [],
topUpstreamsResponses: [],
ignored: [],
ignored_enabled: false,
};
const [state, setState] = createStore<StatsState>(initialState);
@ -120,6 +122,7 @@ export const getStatsConfig = async () => {
? data.interval / HOUR
: null,
ignored: data.ignored || [],
ignored_enabled: data.ignored_enabled ?? false,
processingGetConfig: false,
});
} catch (error) {
@ -128,15 +131,16 @@ export const getStatsConfig = async () => {
}
};
export const setStatsConfig = async (values: any) => {
export const setStatsConfig = async (values: any): Promise<boolean> => {
setState('processingSetConfig', true);
try {
await apiClient.setStatsConfig(values);
setState({ ...values, processingSetConfig: false });
addSuccessToast(intl.getMessage('changes_saved_success'));
return true;
} catch (error) {
addErrorToast({ error });
setState('processingSetConfig', false);
return false;
}
};