mirror of
https://github.com/AdguardTeam/AdGuardHome.git
synced 2026-08-04 15:28:58 +00:00
Pull request 2712: AGDNS-4229 add textarea comments highlight
Some checks are pending
build / test (macOS-latest) (push) Waiting to run
build / test (ubuntu-latest) (push) Waiting to run
build / test (windows-latest) (push) Waiting to run
build / build-release (push) Blocked by required conditions
build / notify (push) Blocked by required conditions
lint / go-lint (push) Waiting to run
lint / eslint (push) Waiting to run
lint / notify (push) Blocked by required conditions
Some checks are pending
build / test (macOS-latest) (push) Waiting to run
build / test (ubuntu-latest) (push) Waiting to run
build / test (windows-latest) (push) Waiting to run
build / build-release (push) Blocked by required conditions
build / notify (push) Blocked by required conditions
lint / go-lint (push) Waiting to run
lint / eslint (push) Waiting to run
lint / notify (push) Blocked by required conditions
Added comments highlight for all upstream fields and custom rules Squashed commit of the following: commit2feb4d4dc1Merge:b5bfdc81c7729d8c57Author: Ildar Kamalov <ik@adguard.com> Date: Thu Jul 16 15:27:54 2026 +0300 Merge branch 'master' into AGDNS-4229 commitb5bfdc81c4Author: Ildar Kamalov <ik@adguard.com> Date: Thu Jul 16 11:33:09 2026 +0300 support all comments for ignored domains commitdecb5b6587Author: Ildar Kamalov <ik@adguard.com> Date: Wed Jul 15 17:58:39 2026 +0300 fix bootstrap and ignored domains commitbd7d342b2fAuthor: Ildar Kamalov <ik@adguard.com> Date: Wed Jul 15 14:40:53 2026 +0300 remove output from test frontend commit5c9de23626Author: Ildar Kamalov <ik@adguard.com> Date: Wed Jul 15 14:18:50 2026 +0300 fix upstream comments commite103540657Author: Ildar Kamalov <ik@adguard.com> Date: Wed Jul 15 13:32:34 2026 +0300 add comments for clients upstreams commita65ee2cf2aAuthor: Ildar Kamalov <ik@adguard.com> Date: Wed Jul 15 12:30:04 2026 +0300 config commit67207b0464Author: Ildar Kamalov <ik@adguard.com> Date: Wed Jul 15 12:06:04 2026 +0300 fix protection menu commit380ac0797eMerge:10945e24dc1f208ef6Author: Ildar Kamalov <ik@adguard.com> Date: Wed Jul 15 11:55:28 2026 +0300 Merge branch 'master' into AGDNS-4229 commit10945e24dfAuthor: Ildar Kamalov <ik@adguard.com> Date: Tue Jul 14 17:54:41 2026 +0300 fix dockerfile commitfaedfede45Author: Ildar Kamalov <ik@adguard.com> Date: Tue Jul 14 17:27:53 2026 +0300 makefile commitb4723ebceaAuthor: Ildar Kamalov <ik@adguard.com> Date: Tue Jul 14 17:27:38 2026 +0300 AGDNS-4229 add textarea comments highlight commit4923b3645bAuthor: Ildar Kamalov <ik@adguard.com> Date: Tue Jul 14 17:13:31 2026 +0300 AGDNS-4229 add textarea comments highlight
This commit is contained in:
parent
7729d8c57d
commit
ef585d45cd
30 changed files with 331 additions and 180 deletions
|
|
@ -62,7 +62,6 @@
|
|||
--build-arg "BASE_IMAGE=${bamboo_dockerFrontend}" \
|
||||
--build-arg "CACHE_BUSTER=${bamboo_cacheBuster}" \
|
||||
--build-arg "CLIENT_DIR=${bamboo_clientDir}" \
|
||||
--output '.' \
|
||||
--progress 'plain' \
|
||||
--target 'tester' \
|
||||
-f ./docker/frontend.Dockerfile \
|
||||
|
|
@ -222,7 +221,6 @@
|
|||
--build-arg "BASE_IMAGE=${bamboo_dockerFrontend}" \
|
||||
--build-arg "CACHE_BUSTER=${bamboo_cacheBuster}" \
|
||||
--build-arg "CLIENT_DIR=${bamboo_clientDir}" \
|
||||
--output '.' \
|
||||
--progress 'plain' \
|
||||
--target 'e2etester' \
|
||||
-f ./docker/frontend.Dockerfile \
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||
import {
|
||||
validateIdentifier,
|
||||
validateUpstreams,
|
||||
validateBootstrapDns,
|
||||
validateRequiredValue,
|
||||
validateIpv4,
|
||||
validatePort,
|
||||
|
|
@ -503,24 +504,70 @@ describe('validateRewriteNotSame', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('validateUpstreams with bang comments', () => {
|
||||
it('accepts lines starting with !', () => {
|
||||
expect(validateUpstreams('! comment line\n8.8.8.8')).toBeUndefined();
|
||||
describe('validateUpstreams with bang prefix', () => {
|
||||
it('rejects lines starting with ! as invalid upstreams', () => {
|
||||
expect(validateUpstreams('! comment line\n8.8.8.8')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepts only comment lines (!)', () => {
|
||||
expect(validateUpstreams('! first\n! second')).toBeUndefined();
|
||||
it('rejects only ! lines as invalid', () => {
|
||||
expect(validateUpstreams('! first\n! second')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects !-prefixed URLs that would otherwise pass address check', () => {
|
||||
expect(validateUpstreams('! https://dns10.quad9.net/dns-query')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects !-prefixed upstream with dot in comment', () => {
|
||||
expect(validateUpstreams('! dns.example.com:53')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('still accepts # comments', () => {
|
||||
expect(validateUpstreams('# comment\n8.8.8.8')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects invalid non-comment lines alongside comments', () => {
|
||||
it('rejects ! and invalid non-comment lines', () => {
|
||||
expect(validateUpstreams('! good\ninvalidline')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBootstrapDns', () => {
|
||||
it('returns undefined for empty value', () => {
|
||||
expect(validateBootstrapDns('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid IP', () => {
|
||||
expect(validateBootstrapDns('8.8.8.8')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for valid upstream URL', () => {
|
||||
expect(validateBootstrapDns('https://dns.example.com/dns-query')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects # comment lines (no comment support)', () => {
|
||||
expect(validateBootstrapDns('# comment\n8.8.8.8')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects #-prefixed URLs that would otherwise pass address check', () => {
|
||||
expect(validateBootstrapDns('# https://dns.example.com')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects only # lines', () => {
|
||||
expect(validateBootstrapDns('# first\n# second')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects !-prefixed lines', () => {
|
||||
expect(validateBootstrapDns('! https://dns.example.com')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects lines without dot or colon', () => {
|
||||
expect(validateBootstrapDns('not-a-server')).toBe('Invalid format');
|
||||
});
|
||||
|
||||
it('returns line-numbered error for mixed valid and # comment', () => {
|
||||
expect(validateBootstrapDns('8.8.8.8\n# comment')).toBe('Invalid format on line 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLeaseTime', () => {
|
||||
it('accepts 1 (minimum)', () => {
|
||||
expect(validateLeaseTime(1)).toBeUndefined();
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ describe('validateDomainsPerLine', () => {
|
|||
expect(validateDomainsPerLine('# this is a comment')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects !-prefixed filter rule that would otherwise pass dot check', () => {
|
||||
expect(validateDomainsPerLine('! ||example.org^')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rejects only ! lines as invalid', () => {
|
||||
expect(validateDomainsPerLine('! first\n! second')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns undefined for mixed valid lines with comments', () => {
|
||||
expect(
|
||||
validateDomainsPerLine('# comment\nexample.org\n||ads.example.org^'),
|
||||
|
|
|
|||
|
|
@ -1,23 +1,47 @@
|
|||
import { type JSX, Show } from 'solid-js';
|
||||
import { type JSX, Show, createSignal, createEffect } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import s from './styles.module.pcss';
|
||||
import { TextareaHighlight } from './TextareaHighlight';
|
||||
|
||||
import type { CommentLineToken } from 'panel/helpers/constants';
|
||||
|
||||
type TextareaChangeEvent = Event & {
|
||||
currentTarget: HTMLTextAreaElement;
|
||||
target: HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
type Props = Omit<JSX.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'onBlur'> & {
|
||||
type Props = Omit<
|
||||
JSX.TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
'onChange' | 'onBlur' | 'onInput' | 'onScroll'
|
||||
> & {
|
||||
label?: JSX.Element;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
errorMessage?: string;
|
||||
highlightComments?: boolean;
|
||||
commentPrefixes?: readonly CommentLineToken[];
|
||||
ref?: HTMLTextAreaElement | ((el: HTMLTextAreaElement) => void);
|
||||
onChange?: (event: TextareaChangeEvent) => void;
|
||||
onInput?: (event: TextareaChangeEvent) => void;
|
||||
onBlur?: (event: FocusEvent) => void;
|
||||
onScroll?: (
|
||||
event: Event & { currentTarget: HTMLTextAreaElement; target: HTMLTextAreaElement },
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const Textarea = (props: Props) => {
|
||||
const [scrollTop, setScrollTop] = createSignal(0);
|
||||
const [currentValue, setCurrentValue] = createSignal('');
|
||||
|
||||
// Sync from external prop changes (e.g. dialog open/close resets);
|
||||
// also handles initial value on mount. Runs before browser paint so
|
||||
// there is no visible flicker from the empty initial signal.
|
||||
createEffect(() => {
|
||||
setCurrentValue(props.value as string);
|
||||
});
|
||||
|
||||
const highlightEnabled = () => !!props.highlightComments;
|
||||
|
||||
const setRef = (el: HTMLTextAreaElement) => {
|
||||
if (typeof props.ref === 'function') {
|
||||
props.ref(el);
|
||||
|
|
@ -28,10 +52,22 @@ export const Textarea = (props: Props) => {
|
|||
props.onChange?.(e);
|
||||
};
|
||||
|
||||
const handleInput = (e: TextareaChangeEvent) => {
|
||||
setCurrentValue(e.target.value);
|
||||
props.onInput?.(e);
|
||||
};
|
||||
|
||||
const handleBlur = (e: FocusEvent) => {
|
||||
props.onBlur?.(e);
|
||||
};
|
||||
|
||||
const handleScroll = (
|
||||
e: Event & { currentTarget: HTMLTextAreaElement; target: HTMLTextAreaElement },
|
||||
) => {
|
||||
setScrollTop(e.target.scrollTop);
|
||||
props.onScroll?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={s.textareaWrapper}>
|
||||
<Show when={props.label}>
|
||||
|
|
@ -39,26 +75,38 @@ export const Textarea = (props: Props) => {
|
|||
{props.label}
|
||||
</label>
|
||||
</Show>
|
||||
<textarea
|
||||
class={cn(
|
||||
s.textarea,
|
||||
props.size && s[props.size],
|
||||
{ [s.error]: !!props.errorMessage },
|
||||
props.class,
|
||||
)}
|
||||
id={props.id}
|
||||
name={props.name}
|
||||
placeholder={props.placeholder}
|
||||
value={props.value as string}
|
||||
cols={props.cols}
|
||||
rows={props.rows}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
wrap={props.wrap}
|
||||
maxLength={props.maxLength}
|
||||
disabled={props.disabled}
|
||||
ref={(el) => setRef(el)}
|
||||
/>
|
||||
<div class={s.textareaContainer}>
|
||||
<textarea
|
||||
class={cn(
|
||||
s.textarea,
|
||||
props.size && s[props.size],
|
||||
{ [s.error]: !!props.errorMessage },
|
||||
{ [s.transparentText]: highlightEnabled() },
|
||||
props.class,
|
||||
)}
|
||||
id={props.id}
|
||||
name={props.name}
|
||||
placeholder={props.placeholder}
|
||||
value={props.value as string}
|
||||
cols={props.cols}
|
||||
rows={props.rows}
|
||||
onChange={handleChange}
|
||||
onInput={handleInput}
|
||||
onBlur={handleBlur}
|
||||
onScroll={handleScroll}
|
||||
wrap={props.wrap}
|
||||
maxLength={props.maxLength}
|
||||
disabled={props.disabled}
|
||||
ref={(el) => setRef(el)}
|
||||
/>
|
||||
<Show when={highlightEnabled()}>
|
||||
<TextareaHighlight
|
||||
value={currentValue}
|
||||
scrollTop={scrollTop}
|
||||
commentPrefixes={props.commentPrefixes}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.errorMessage}>
|
||||
<div class={s.errorMessage}>{props.errorMessage}</div>
|
||||
</Show>
|
||||
|
|
|
|||
58
client_v2/src/common/controls/Textarea/TextareaHighlight.tsx
Normal file
58
client_v2/src/common/controls/Textarea/TextareaHighlight.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { For, type Accessor, createEffect, createMemo } from 'solid-js';
|
||||
import cn from 'clsx';
|
||||
|
||||
import { COMMENT_LINE_DEFAULT_TOKEN, type CommentLineToken } from 'panel/helpers/constants';
|
||||
|
||||
import s from './styles.module.pcss';
|
||||
|
||||
type Props = {
|
||||
value: Accessor<string>;
|
||||
scrollTop: Accessor<number>;
|
||||
class?: string;
|
||||
/** Comment line prefixes to highlight. Defaults to {@link COMMENT_LINE_DEFAULT_TOKEN}. */
|
||||
commentPrefixes?: readonly CommentLineToken[];
|
||||
};
|
||||
|
||||
export const TextareaHighlight = (props: Props) => {
|
||||
const lines = () => (props.value() || '').split('\n');
|
||||
let ref: HTMLDivElement | undefined;
|
||||
|
||||
const prefixes = createMemo(() => props.commentPrefixes || [COMMENT_LINE_DEFAULT_TOKEN]);
|
||||
|
||||
const isComment = (line: string) => {
|
||||
const trimmed = line.trimStart();
|
||||
return prefixes().some((p) => trimmed.startsWith(p));
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const el = ref;
|
||||
if (el) {
|
||||
el.scrollTop = props.scrollTop();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
ref = el;
|
||||
}}
|
||||
class={cn(s.overlay, props.class)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<For each={lines()}>
|
||||
{(line, index) => (
|
||||
<>
|
||||
{index() > 0 && '\n'}
|
||||
<span
|
||||
class={cn({
|
||||
[s.commentLine]: isComment(line),
|
||||
})}
|
||||
>
|
||||
{line || ' '}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
.textarea {
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--default-item-divider);
|
||||
|
|
@ -61,6 +62,48 @@
|
|||
color: var(--default-description-text);
|
||||
}
|
||||
|
||||
.textareaContainer {
|
||||
position: relative;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
padding: 16px;
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.commentLine {
|
||||
color: var(--default-additional-link);
|
||||
}
|
||||
|
||||
.transparentText {
|
||||
color: transparent;
|
||||
caret-color: var(--default-main-text);
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--default-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
|
|
|
|||
|
|
@ -110,8 +110,8 @@ export const Menu = (props: Props) => {
|
|||
},
|
||||
{
|
||||
label: intl.getMessage('user_rules_title'),
|
||||
path: Paths.UserRules,
|
||||
routePath: RoutePath.UserRules,
|
||||
path: Paths.CustomRules,
|
||||
routePath: RoutePath.CustomRules,
|
||||
},
|
||||
]}
|
||||
isActive={isActive}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { AddClient } from '../Clients/AddClient';
|
|||
import { Protection } from '../Clients/AddClient/blocks/Protection/Protection';
|
||||
import { ClientBlockedServices } from '../Clients/AddClient/blocks/ClientBlockedServices';
|
||||
import { ClientSchedule } from '../Clients/AddClient/blocks/ClientSchedule';
|
||||
import { Paths } from '../Routes/Paths';
|
||||
|
||||
const SetupGuideRoute = () => <SetupGuide />;
|
||||
const BlockedServicesRoute = () => <BlockedServices />;
|
||||
|
|
@ -124,36 +125,30 @@ const App = () => {
|
|||
</>
|
||||
)}
|
||||
>
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/encryption" component={Encryption} />
|
||||
<Route path="/dns" component={DnsSettings} />
|
||||
<Route path="/dns/private-reverse" component={PrivateReverse} />
|
||||
<Route path="/blocklists" component={Blocklists} />
|
||||
<Route path="/allowlists" component={Allowlists} />
|
||||
<Route path="/user_rules" component={UserRules} />
|
||||
<Route path="/dns_rewrites" component={DNSRewrites} />
|
||||
<Route path="/dhcp" component={Dhcp} />
|
||||
<Route path="/dhcp/leases" component={LeasesPage} />
|
||||
<Route path="/guide" component={SetupGuideRoute} />
|
||||
<Route path="/logs" component={QueryLog} />
|
||||
<Route path="/blocked_services/schedule" component={InactivityScheduleRoute} />
|
||||
<Route path="/blocked_services" component={BlockedServicesRoute} />
|
||||
<Route path="/clients/add/blocked_services/schedule" component={ClientScheduleRoute} />
|
||||
<Route path="/clients/add/blocked_services" component={ClientBlockedServicesRoute} />
|
||||
<Route path="/clients/add/protection" component={ProtectionRoute} />
|
||||
<Route path="/clients/add" component={AddClientRoute} />
|
||||
<Route
|
||||
path="/clients/edit/:clientName/blocked_services/schedule"
|
||||
component={ClientScheduleRoute}
|
||||
/>
|
||||
<Route
|
||||
path="/clients/edit/:clientName/blocked_services"
|
||||
component={ClientBlockedServicesRoute}
|
||||
/>
|
||||
<Route path="/clients/edit/:clientName/protection" component={ProtectionRoute} />
|
||||
<Route path="/clients/edit/:clientName" component={AddClientRoute} />
|
||||
<Route path="/clients" component={Clients} />
|
||||
<Route path={Paths.Dashboard} component={Dashboard} />
|
||||
<Route path={Paths.SettingsPage} component={Settings} />
|
||||
<Route path={Paths.Encryption} component={Encryption} />
|
||||
<Route path={Paths.Dns} component={DnsSettings} />
|
||||
<Route path={Paths.DnsPrivateReverse} component={PrivateReverse} />
|
||||
<Route path={Paths.DnsBlocklists} component={Blocklists} />
|
||||
<Route path={Paths.DnsAllowlists} component={Allowlists} />
|
||||
<Route path={Paths.CustomRules} component={UserRules} />
|
||||
<Route path={Paths.DnsRewrites} component={DNSRewrites} />
|
||||
<Route path={Paths.Dhcp} component={Dhcp} />
|
||||
<Route path={Paths.DhcpLeases} component={LeasesPage} />
|
||||
<Route path={Paths.Guide} component={SetupGuideRoute} />
|
||||
<Route path={Paths.Logs} component={QueryLog} />
|
||||
<Route path={Paths.InactivitySchedule} component={InactivityScheduleRoute} />
|
||||
<Route path={Paths.BlockedServices} component={BlockedServicesRoute} />
|
||||
<Route path={Paths.ClientsSchedule} component={ClientScheduleRoute} />
|
||||
<Route path={Paths.ClientsBlockedServices} component={ClientBlockedServicesRoute} />
|
||||
<Route path={Paths.ClientsProtection} component={ProtectionRoute} />
|
||||
<Route path={Paths.ClientsAdd} component={AddClientRoute} />
|
||||
<Route path={Paths.ClientsEditSchedule} component={ClientScheduleRoute} />
|
||||
<Route path={Paths.ClientsEditBlockedServices} component={ClientBlockedServicesRoute} />
|
||||
<Route path={Paths.ClientsEditProtection} component={ProtectionRoute} />
|
||||
<Route path={Paths.ClientsEdit} component={AddClientRoute} />
|
||||
<Route path={Paths.Clients} component={Clients} />
|
||||
<Route path="/" component={() => <Navigate href="/dashboard" />} />
|
||||
</HashRouter>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -324,6 +324,7 @@ export const AddClient = () => {
|
|||
rows={4}
|
||||
disabled={clientFormState.use_global_settings}
|
||||
errorMessage={upstreamsError()}
|
||||
highlightComments
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,14 @@
|
|||
.protectionMenu {
|
||||
padding: 8px 0;
|
||||
min-width: 200px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
min-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.protectionMenuItem {
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
.periodSettingsFooter {
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ export const Header = (props: Props) => {
|
|||
theme.select.option_check,
|
||||
theme.text.t2,
|
||||
theme.text.condenced,
|
||||
s.protectionMenuItem,
|
||||
)}
|
||||
onMouseDown={() => handleDisableProtection(item.time)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog';
|
|||
import intl from 'panel/common/intl';
|
||||
import theme from 'panel/lib/theme';
|
||||
import { useDialog } from 'panel/hooks/useDialog';
|
||||
import { SETTINGS_URLS } from 'panel/helpers/constants';
|
||||
import { Paths } from 'panel/components/Routes/Paths';
|
||||
import {
|
||||
dhcpState,
|
||||
getDhcpStatus,
|
||||
|
|
@ -189,7 +189,7 @@ export const Dhcp = () => {
|
|||
id="dhcp_leases_link"
|
||||
variant="link"
|
||||
title={intl.getMessage('dhcp_leases_title')}
|
||||
onClick={() => navigate(SETTINGS_URLS.dhcpLeases)}
|
||||
onClick={() => navigate(Paths.DhcpLeases)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { dnsConfigState, setDnsConfig } from 'panel/stores/dnsConfig';
|
|||
import intl from 'panel/common/intl';
|
||||
import { ConfigDialog } from 'panel/common/ui/ConfigDialog';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { validateUpstreams } from 'panel/helpers/validators';
|
||||
import { validateBootstrapDns } from 'panel/helpers/validators';
|
||||
import { useField } from 'panel/hooks/useField';
|
||||
|
||||
import theme from 'panel/lib/theme';
|
||||
|
|
@ -18,7 +18,7 @@ export const BootstrapDnsDialog = (props: Props) => {
|
|||
const field = useField<string>(
|
||||
() => props.open(),
|
||||
() => dnsConfigState.bootstrap_dns,
|
||||
{ validate: (v) => (v ? validateUpstreams(v) || '' : '') },
|
||||
{ validate: (v) => (v ? validateBootstrapDns(v) || '' : '') },
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export const FallbackDnsDialog = (props: Props) => {
|
|||
placeholder={intl.getMessage('dns_fallback_dns_placeholder')}
|
||||
errorMessage={field.error()}
|
||||
size="medium"
|
||||
highlightComments
|
||||
/>
|
||||
</div>
|
||||
<Examples />
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export const PrivateReverseServersDialog = (props: Props) => {
|
|||
placeholder={intl.getMessage('dns_private_reverse_servers_placeholder')}
|
||||
errorMessage={field.error()}
|
||||
size="medium"
|
||||
highlightComments
|
||||
/>
|
||||
</div>
|
||||
<Examples />
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export const ServerAddressesDialog = (props: Props) => {
|
|||
placeholder={intl.getMessage('dns_server_addresses_placeholder')}
|
||||
errorMessage={field.error()}
|
||||
size="medium"
|
||||
highlightComments
|
||||
/>
|
||||
</div>
|
||||
<Examples />
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ export const RoutePath = {
|
|||
DnsRewrites: 'DnsRewrites',
|
||||
BlockedServices: 'BlockedServices',
|
||||
InactivitySchedule: 'InactivitySchedule',
|
||||
UserRules: 'UserRules',
|
||||
CustomRules: 'CustomRules',
|
||||
DhcpLeases: 'DhcpLeases',
|
||||
QueryLog: 'QueryLog',
|
||||
ClientsAdd: 'ClientsAdd',
|
||||
ClientsProtection: 'ClientsProtection',
|
||||
|
|
@ -50,7 +51,8 @@ export const Paths: Record<RoutePathKey, string> = {
|
|||
DnsRewrites: pathBuilder('dns_rewrites'),
|
||||
BlockedServices: pathBuilder('blocked_services'),
|
||||
InactivitySchedule: pathBuilder('blocked_services/schedule'),
|
||||
UserRules: pathBuilder('user_rules'),
|
||||
CustomRules: pathBuilder('custom_rules'),
|
||||
DhcpLeases: pathBuilder('dhcp/leases'),
|
||||
QueryLog: pathBuilder('logs'),
|
||||
ClientsAdd: pathBuilder('clients/add'),
|
||||
ClientsProtection: pathBuilder('clients/add/protection'),
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export const FiltersConfig = (props: Props) => {
|
|||
),
|
||||
c: (text: string) => (
|
||||
<Link
|
||||
to={RoutePath.UserRules}
|
||||
to={RoutePath.CustomRules}
|
||||
class={theme.link.link}
|
||||
onClick={(e: Event) => e.stopPropagation()}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import theme from 'panel/lib/theme';
|
|||
import { trimLinesAndRemoveEmpty } from 'panel/helpers/helpers';
|
||||
|
||||
import s from './IgnoredDomainsModal.module.pcss';
|
||||
import { COMMENT_LINE_TOKENS } from 'panel/helpers/constants';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
|
|
@ -88,6 +89,8 @@ export const IgnoredDomainsModal = (props: Props) => {
|
|||
placeholder={`example.com\n*.example.com\n||example.com^`}
|
||||
size="large"
|
||||
disabled={props.processing}
|
||||
commentPrefixes={COMMENT_LINE_TOKENS}
|
||||
highlightComments
|
||||
/>
|
||||
</ConfigDialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@
|
|||
.editorTextarea {
|
||||
resize: vertical;
|
||||
min-height: 260px;
|
||||
white-space: pre;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.editorSubmitButton {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { servicesState, getBlockedServices, getAllBlockedServices } from 'panel/
|
|||
import { rewritesState, getRewritesList } from 'panel/stores/rewrites';
|
||||
import { MODAL_TYPE } from 'panel/helpers/constants';
|
||||
import theme from 'panel/lib/theme';
|
||||
import { Loader } from 'panel/common/ui/Loader';
|
||||
import { Loader, PageLoader } from 'panel/common/ui/Loader';
|
||||
import { ConfigureRewritesModal } from 'panel/components/FilterLists/blocks/ConfigureRewritesModal/ConfigureRewritesModal';
|
||||
import { DeleteRewriteModal } from 'panel/components/FilterLists/blocks/DeleteRewriteModal';
|
||||
|
||||
|
|
@ -30,6 +30,8 @@ export const UserRules = () => {
|
|||
const [lastSubmittedCheck, setLastSubmittedCheck] = createSignal<CheckFormValues | null>(null);
|
||||
const [isResultVisible, setIsResultVisible] = createSignal(false);
|
||||
const [isResultRefreshing, setIsResultRefreshing] = createSignal(false);
|
||||
// Skip loader if store already has data (SPA revisit, no page reload).
|
||||
const [isLoaded, setIsLoaded] = createSignal(filteringState.filters.length > 0);
|
||||
|
||||
const [userRulesValue, setUserRulesValue] = createSignal(filteringState.userRules || '');
|
||||
|
||||
|
|
@ -77,6 +79,7 @@ export const UserRules = () => {
|
|||
getBlockedServices(),
|
||||
getAllBlockedServices(),
|
||||
]);
|
||||
setIsLoaded(true);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
|
|
@ -131,7 +134,7 @@ export const UserRules = () => {
|
|||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={isLoaded()} fallback={<PageLoader />}>
|
||||
<div class={theme.layout.container}>
|
||||
<div class={s.container}>
|
||||
<div class={s.wrapper}>
|
||||
|
|
@ -205,6 +208,6 @@ export const UserRules = () => {
|
|||
setRewriteToDelete={setCurrentRewrite}
|
||||
onConfirm={handleRewriteDelete}
|
||||
/>
|
||||
</>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import theme from 'panel/lib/theme';
|
|||
import intl from 'panel/common/intl';
|
||||
import { Textarea } from 'panel/common/controls/Textarea';
|
||||
import { Button } from 'panel/common/ui/Button';
|
||||
import { COMMENT_LINE_TOKENS } from 'panel/helpers/constants';
|
||||
|
||||
import s from '../UserRules.module.pcss';
|
||||
|
||||
|
|
@ -37,6 +38,8 @@ export const RulesEditor = (props: Props) => {
|
|||
onChange={(e: Event) =>
|
||||
props.onChange((e.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
highlightComments
|
||||
commentPrefixes={COMMENT_LINE_TOKENS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -162,29 +162,6 @@ export const CLIENT_ID = {
|
|||
IP: 'ip',
|
||||
};
|
||||
|
||||
export const MENU_URLS = {
|
||||
root: '/',
|
||||
logs: '/logs',
|
||||
guide: '/guide',
|
||||
};
|
||||
|
||||
export const SETTINGS_URLS = {
|
||||
encryption: '/encryption',
|
||||
dhcp: '/dhcp',
|
||||
dhcpLeases: '/dhcp/leases',
|
||||
dns: '/dns',
|
||||
settings: '/settings',
|
||||
clients: '/clients',
|
||||
};
|
||||
|
||||
export const FILTERS_URLS = {
|
||||
dns_blocklists: '/filters',
|
||||
dns_allowlists: '/dns_allowlists',
|
||||
dns_rewrites: '/dns_rewrites',
|
||||
user_rules: '/user_rules',
|
||||
blocked_services: '/blocked_services',
|
||||
};
|
||||
|
||||
export const ENCRYPTION_SOURCE = {
|
||||
PATH: 'path',
|
||||
CONTENT: 'content',
|
||||
|
|
@ -593,8 +570,9 @@ export const CACHE_CONFIG_FIELDS = {
|
|||
cache_ttl_max: 'cache_ttl_max',
|
||||
};
|
||||
|
||||
export const isFirefox = navigator.userAgent.indexOf('Firefox') !== -1;
|
||||
export const COMMENT_LINE_DEFAULT_TOKEN = '#';
|
||||
export const COMMENT_LINE_TOKENS = ['#', '!'] as const;
|
||||
export type CommentLineToken = (typeof COMMENT_LINE_TOKENS)[number];
|
||||
|
||||
export const MOBILE_CONFIG_LINKS = {
|
||||
DOT: 'apple/dot.mobileconfig',
|
||||
|
|
|
|||
|
|
@ -1172,6 +1172,8 @@ export const getBlockingClientName = (clients: any, ip: any) => {
|
|||
export const filterOutComments = (lines: any) =>
|
||||
lines.filter((line: any) => !line.startsWith(COMMENT_LINE_DEFAULT_TOKEN));
|
||||
|
||||
export const isCommentLine = (line: string) => /^\s*[#!]/.test(line);
|
||||
|
||||
/**
|
||||
* @param {array} services
|
||||
* @param {string} id
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
import classnames from 'clsx';
|
||||
import theme from 'panel/lib/theme';
|
||||
import { COMMENT_LINE_DEFAULT_TOKEN } from './constants';
|
||||
|
||||
type CommentLineTokens = string[];
|
||||
|
||||
const renderHighlightedLine = (
|
||||
line: string,
|
||||
idx: number,
|
||||
commentLineTokens: CommentLineTokens = [COMMENT_LINE_DEFAULT_TOKEN],
|
||||
) => {
|
||||
const isComment = commentLineTokens.some((token) => line.trim().startsWith(token));
|
||||
|
||||
const lineClassName = classnames({
|
||||
[theme.highlight.textGray]: isComment,
|
||||
[theme.highlight.textTransparent]: !isComment,
|
||||
});
|
||||
|
||||
return <div class={lineClassName}>{line || '\n'}</div>;
|
||||
};
|
||||
|
||||
export const getTextareaCommentsHighlight = (
|
||||
ref: HTMLElement | undefined,
|
||||
lines: string,
|
||||
commentLineTokens: CommentLineTokens = [COMMENT_LINE_DEFAULT_TOKEN],
|
||||
className = '',
|
||||
) => {
|
||||
const renderLine = (line: string, idx: number) =>
|
||||
renderHighlightedLine(line, idx, commentLineTokens);
|
||||
|
||||
return (
|
||||
<code class={classnames(theme.highlight.textOutput, className)} ref={ref}>
|
||||
{lines.split('\n').map(renderLine)}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
|
||||
export const syncScroll = (e: UIEvent, ref: HTMLElement | undefined) => {
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
ref.scrollTop = target.scrollTop;
|
||||
};
|
||||
|
|
@ -665,8 +665,10 @@ export const validateHostname = (value?: string): ValidationResult => {
|
|||
return undefined;
|
||||
};
|
||||
|
||||
// A valid upstream line contains at least one dot or colon.
|
||||
const R_COMMENT = /^\s*[#!]/;
|
||||
// A valid upstream or blocked_hosts line contains at least one dot or colon.
|
||||
// Only # is a valid comment prefix for upstreams / blocked_hosts.
|
||||
const R_UPSTREAM_COMMENT = /^\s*#/;
|
||||
const R_BANG_PREFIX = /^\s*!/;
|
||||
const R_HAS_ADDRESS = /[.:]/;
|
||||
|
||||
// A valid blocked_hosts entry contains at least one dot.
|
||||
|
|
@ -677,13 +679,32 @@ const R_HAS_DOT = /[.]/;
|
|||
*
|
||||
* @example validateUpstreams("https://dns.example.com") // undefined (valid)
|
||||
* @example validateUpstreams("# comment\n1.1.1.1") // undefined (comments ok)
|
||||
* @example validateUpstreams("not-a-server") // "Invalid upstream"
|
||||
* @example validateUpstreams("! https://dns.example.com") // error (! not supported)
|
||||
* @example validateUpstreams("not-a-server") // error (no dot or colon)
|
||||
*/
|
||||
export const validateUpstreams = (value: string): string | undefined =>
|
||||
validatePerLine(
|
||||
value,
|
||||
(line) => R_HAS_ADDRESS.test(line),
|
||||
(line) => !R_COMMENT.test(line),
|
||||
(line) => R_HAS_ADDRESS.test(line) && !R_BANG_PREFIX.test(line),
|
||||
(line) => !R_UPSTREAM_COMMENT.test(line),
|
||||
);
|
||||
|
||||
/**
|
||||
* Validates bootstrap DNS server lines. Unlike {@link validateUpstreams},
|
||||
* comments are NOT allowed here (`#`-prefixed lines are rejected) because
|
||||
* the backend does not filter comments from bootstrap DNS entries.
|
||||
* Each line must contain a dot or colon.
|
||||
*
|
||||
* @example validateBootstrapDns("8.8.8.8") // undefined (valid)
|
||||
* @example validateBootstrapDns("# comment\n8.8.8.8") // error (# not supported)
|
||||
* @example validateBootstrapDns("! https://dns.example.com") // error (! not supported)
|
||||
* @example validateBootstrapDns("not-a-server") // error (no dot or colon)
|
||||
*/
|
||||
export const validateBootstrapDns = (value: string): string | undefined =>
|
||||
validatePerLine(
|
||||
value,
|
||||
(line) =>
|
||||
R_HAS_ADDRESS.test(line) && !R_BANG_PREFIX.test(line) && !R_UPSTREAM_COMMENT.test(line),
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -694,13 +715,14 @@ export const validateUpstreams = (value: string): string | undefined =>
|
|||
* @example validateDomainsPerLine("*.example.org") // undefined (valid)
|
||||
* @example validateDomainsPerLine("||example.org^") // undefined (valid)
|
||||
* @example validateDomainsPerLine("# comment") // undefined (comments ok)
|
||||
* @example validateDomainsPerLine("notadomain") // "Invalid format"
|
||||
* @example validateDomainsPerLine("! ||example.org^") // error (! not supported)
|
||||
* @example validateDomainsPerLine("notadomain") // error (no dot)
|
||||
*/
|
||||
export const validateDomainsPerLine = (value: string): string | undefined =>
|
||||
validatePerLine(
|
||||
value,
|
||||
(line) => R_HAS_DOT.test(line),
|
||||
(line) => !R_COMMENT.test(line),
|
||||
(line) => R_HAS_DOT.test(line) && !R_BANG_PREFIX.test(line),
|
||||
(line) => !R_UPSTREAM_COMMENT.test(line),
|
||||
);
|
||||
|
||||
interface LeaseEntry {
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
.textOutput {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-y: auto;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.textGray {
|
||||
color: var(--disabled-main-text);
|
||||
}
|
||||
|
||||
.textTransparent {
|
||||
color: transparent;
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ import select from './Select.module.pcss';
|
|||
import form from './Form.module.pcss';
|
||||
import pagination from './Pagination.module.pcss';
|
||||
import status from './Status.module.pcss';
|
||||
import highlight from './Highlight.module.pcss';
|
||||
|
||||
const theme = {
|
||||
link,
|
||||
|
|
@ -25,7 +24,6 @@ const theme = {
|
|||
form,
|
||||
pagination,
|
||||
status,
|
||||
highlight,
|
||||
};
|
||||
|
||||
export default theme;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import { untrack } from 'solid-js';
|
|||
import { apiClient } from 'panel/api/Api';
|
||||
import { addErrorToast, addSuccessToast } from './toasts';
|
||||
import intl from 'panel/common/intl';
|
||||
import { SETTINGS_URLS, STATUS_RESPONSE } from 'panel/helpers/constants';
|
||||
import { STATUS_RESPONSE } from 'panel/helpers/constants';
|
||||
import { Paths } from 'panel/components/Routes/Paths';
|
||||
import { enrichWithConcatenatedIpAddresses } from 'panel/helpers/helpers';
|
||||
|
||||
type Lease = { hostname: string; ip: string; mac: string };
|
||||
|
|
@ -148,7 +149,7 @@ export const findActiveDhcp = async (interfaceName: string, navigate?: (path: st
|
|||
error: intl.getMessage('dhcp_static_ip_error'),
|
||||
action: {
|
||||
text: intl.getMessage('set_static_ip_manually'),
|
||||
callback: () => navigate?.(SETTINGS_URLS.dhcpLeases),
|
||||
callback: () => navigate?.(Paths.DhcpLeases),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ export default defineConfig({
|
|||
noExternal: [
|
||||
'@solidjs/testing-library',
|
||||
'@solidjs/router',
|
||||
'solid-js',
|
||||
// Force @zag-js/* into the same module graph so they share
|
||||
// a single solid-js instance, preventing "multiple instances
|
||||
// of Solid" errors in CI.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue