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

Added comments highlight for all upstream fields and custom rules

Squashed commit of the following:

commit 2feb4d4dc1
Merge: b5bfdc81c 7729d8c57
Author: Ildar Kamalov <ik@adguard.com>
Date:   Thu Jul 16 15:27:54 2026 +0300

    Merge branch 'master' into AGDNS-4229

commit b5bfdc81c4
Author: Ildar Kamalov <ik@adguard.com>
Date:   Thu Jul 16 11:33:09 2026 +0300

    support all comments for ignored domains

commit decb5b6587
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Jul 15 17:58:39 2026 +0300

    fix bootstrap and ignored domains

commit bd7d342b2f
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Jul 15 14:40:53 2026 +0300

    remove output from test frontend

commit 5c9de23626
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Jul 15 14:18:50 2026 +0300

    fix upstream comments

commit e103540657
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Jul 15 13:32:34 2026 +0300

    add comments for clients upstreams

commit a65ee2cf2a
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Jul 15 12:30:04 2026 +0300

    config

commit 67207b0464
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Jul 15 12:06:04 2026 +0300

    fix protection menu

commit 380ac0797e
Merge: 10945e24d c1f208ef6
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Jul 15 11:55:28 2026 +0300

    Merge branch 'master' into AGDNS-4229

commit 10945e24df
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Jul 14 17:54:41 2026 +0300

    fix dockerfile

commit faedfede45
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Jul 14 17:27:53 2026 +0300

    makefile

commit b4723ebcea
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Jul 14 17:27:38 2026 +0300

    AGDNS-4229 add textarea comments highlight

commit 4923b3645b
Author: 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:
Ildar Kamalov 2026-07-16 13:02:59 +00:00
parent 7729d8c57d
commit ef585d45cd
30 changed files with 331 additions and 180 deletions

View file

@ -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 \

View file

@ -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();

View file

@ -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^'),

View file

@ -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>

View 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>
);
};

View file

@ -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;

View file

@ -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}

View file

@ -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>
);

View file

@ -324,6 +324,7 @@ export const AddClient = () => {
rows={4}
disabled={clientFormState.use_global_settings}
errorMessage={upstreamsError()}
highlightComments
/>
</div>

View file

@ -90,6 +90,14 @@
.protectionMenu {
padding: 8px 0;
min-width: 200px;
@media (min-width: 768px) {
min-width: 320px;
}
}
.protectionMenuItem {
padding-right: 24px;
}
.periodSettingsFooter {

View file

@ -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)}
>

View file

@ -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>

View file

@ -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 (

View file

@ -63,6 +63,7 @@ export const FallbackDnsDialog = (props: Props) => {
placeholder={intl.getMessage('dns_fallback_dns_placeholder')}
errorMessage={field.error()}
size="medium"
highlightComments
/>
</div>
<Examples />

View file

@ -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 />

View file

@ -82,6 +82,7 @@ export const ServerAddressesDialog = (props: Props) => {
placeholder={intl.getMessage('dns_server_addresses_placeholder')}
errorMessage={field.error()}
size="medium"
highlightComments
/>
</div>
<Examples />

View file

@ -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'),

View file

@ -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()}
>

View file

@ -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>
);

View file

@ -97,7 +97,7 @@
.editorTextarea {
resize: vertical;
min-height: 260px;
white-space: pre;
white-space: pre-wrap;
}
.editorSubmitButton {

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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',

View file

@ -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

View file

@ -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;
};

View file

@ -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 {

View file

@ -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;
}

View file

@ -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;

View file

@ -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),
},
});
}

View file

@ -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.