mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2026-08-04 15:28:58 +00:00
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
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: commit4cbf10e68aAuthor: Ildar Kamalov <ik@adguard.com> Date: Tue Jul 7 11:44:00 2026 +0300 fix value display commit5b9baa8afdAuthor: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 18:38:43 2026 +0300 fix button color commit64a840d660Author: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 18:37:40 2026 +0300 fix button color commit401cbf8dcfAuthor: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 18:36:56 2026 +0300 fix button width commit4b3070a0e4Author: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 16:03:00 2026 +0300 review fix commitebb5a5a675Author: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 15:27:44 2026 +0300 missing toast commit7fd5553b74Author: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 15:18:14 2026 +0300 fix ignored domains commit243713b77eAuthor: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 14:57:27 2026 +0300 fix title commit9e430938aeAuthor: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 13:51:11 2026 +0300 review fix commit2ca383b926Author: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 12:18:26 2026 +0300 review fix commitda1fad5debAuthor: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 11:34:58 2026 +0300 mv to common commit6c47d8a03bAuthor: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 11:02:41 2026 +0300 rm unused commit22258dca29Author: Ildar Kamalov <ik@adguard.com> Date: Mon Jul 6 10:45:43 2026 +0300 AGDNS-4172 update general settings
This commit is contained in:
parent
6aea8e6093
commit
f49ee9fb79
32 changed files with 1459 additions and 542 deletions
119
client_v3/src/__tests__/common/ui/ConfigDialog.test.tsx
Normal file
119
client_v3/src/__tests__/common/ui/ConfigDialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
214
client_v3/src/__tests__/components/Settings/SettingRow.test.tsx
Normal file
214
client_v3/src/__tests__/components/Settings/SettingRow.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
52
client_v3/src/common/ui/ConfigDialog/ConfigDialog.tsx
Normal file
52
client_v3/src/common/ui/ConfigDialog/ConfigDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
client_v3/src/common/ui/ConfigDialog/index.ts
Normal file
1
client_v3/src/common/ui/ConfigDialog/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { ConfigDialog } from './ConfigDialog';
|
||||
122
client_v3/src/common/ui/SettingRow/SettingRow.module.pcss
Normal file
122
client_v3/src/common/ui/SettingRow/SettingRow.module.pcss
Normal 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;
|
||||
}
|
||||
169
client_v3/src/common/ui/SettingRow/SettingRow.tsx
Normal file
169
client_v3/src/common/ui/SettingRow/SettingRow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
client_v3/src/common/ui/SettingRow/index.ts
Normal file
1
client_v3/src/common/ui/SettingRow/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { SettingRow } from './SettingRow';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export { IgnoredDomains } from './IgnoredDomains';
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
.link {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dropdownTitle {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.dropdownTitle {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.link {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { IgnoredDomainsModal } from './IgnoredDomainsModal';
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
.providersGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { SafeSearchModal } from './SafeSearchModal';
|
||||
42
client_v3/src/components/Settings/Settings.module.pcss
Normal file
42
client_v3/src/components/Settings/Settings.module.pcss
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
&_one_col {
|
||||
@media (min-width: 1024px) {
|
||||
max-width: 632px;
|
||||
max-width: 584px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue