Pull request 2688: AGDNS-4164 add update and tls banners
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

Squashed commit of the following:

commit 6a98822c92c37a08bb59966acdaec60012e7f1e0
Author: Ildar Kamalov <ik@adguard.com>
Date:   Wed Jul 1 10:17:50 2026 +0300

    fix footer order

commit 2da1d3da5928c0ea4b276e8618126db01ed9c3e9
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Jun 30 17:49:45 2026 +0300

    fix build

commit 44dad0ee96f41688bd183270704494698516b5f8
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Jun 30 16:06:47 2026 +0300

    fix footer columns

commit d2fff9e8bb562aea208ff80001a64ebfbf37fde2
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Jun 30 15:34:26 2026 +0300

    fix footer version

commit 59420eff26d7290e970ebe664d88cd021d9c7712
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Jun 30 15:27:29 2026 +0300

    fix build

commit b94c8462a2e64df140db5fdc542d088200b95bb6
Author: Ildar Kamalov <ik@adguard.com>
Date:   Tue Jun 30 14:58:34 2026 +0300

    AGDNS-4164 add update and tls banners
This commit is contained in:
Ildar Kamalov 2026-07-01 12:01:04 +00:00
parent 42e7dcc31c
commit 5a55c03ec6
29 changed files with 1098 additions and 137 deletions

View file

@ -33,7 +33,7 @@
- 'Artifact'
- 'E2E':
manual: false
manual: true
final: false
jobs:
- 'Test e2e'

View file

@ -840,5 +840,12 @@
"clients": "Clients",
"aria_clear_input": "Clear input",
"aria_previous_page": "Previous page",
"aria_next_page": "Next page"
"aria_next_page": "Next page",
"update_available": "Version %version% is available. <a>Release notes</a>",
"update_button": "Update",
"update_how_to": "How to update",
"tls_certificate_expiring": "Your TLS certificate is about to expire",
"tls_certificate_expired": "Your TLS certificate has expired",
"version_number": "Version %value%",
"update_failed": "Auto-update failed. Please <a>follow these steps</a> to update manually"
}

View file

@ -35,6 +35,9 @@ vi.mock('panel/stores/dashboard', () => ({
vi.mock('panel/common/ui/Header', () => ({
Header: () => <div data-testid="chrome-header" />,
}));
vi.mock('panel/common/ui/Banners', () => ({
Banners: () => <div data-testid="chrome-banners" />,
}));
vi.mock('panel/common/ui/Sidebar', () => ({
Sidebar: () => <div data-testid="chrome-sidebar" />,
}));
@ -59,4 +62,27 @@ describe('App routing', () => {
expect(await screen.findByTestId('route-dashboard')).toBeInTheDocument();
});
it('mounts Banners between Header and the wrapper in the main entry', async () => {
window.location.hash = '#/dashboard';
render(() => <App />);
const banners = await screen.findByTestId('chrome-banners');
expect(banners).toBeInTheDocument();
// Verify ordering: Header → Banners → Sidebar (wrapper starts)
const header = screen.getByTestId('chrome-header');
const sidebar = screen.getByTestId('chrome-sidebar');
// Banners should be after Header in DOM
expect(
header.compareDocumentPosition(banners) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
// Banners should be before Sidebar in DOM
expect(
banners.compareDocumentPosition(sidebar) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});

View file

@ -0,0 +1,74 @@
import { render, screen } from '@solidjs/testing-library';
import { describe, it, expect, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { Banner } from 'panel/common/ui/Banner';
describe('Banner', () => {
it('renders the message slot', () => {
render(() => <Banner variant="info" message="Test message" />);
expect(screen.getByText('Test message')).toBeInTheDocument();
});
it('renders the action slot when provided', () => {
render(() => (
<Banner
variant="info"
message="Test"
action={<button type="button">Click me</button>}
/>
));
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('applies the correct variant class', () => {
const { container } = render(() => <Banner variant="warning" message="Warning!" />);
const banner = container.firstElementChild as HTMLElement;
expect(banner.className).toContain('warning');
});
it('sets role="alert" and aria-live="assertive" for critical variant', () => {
const { container } = render(() => <Banner variant="critical" message="Critical!" />);
const banner = container.firstElementChild as HTMLElement;
expect(banner.getAttribute('role')).toBe('alert');
expect(banner.getAttribute('aria-live')).toBe('assertive');
});
it('sets role="status" and aria-live="polite" for non-critical variants', () => {
const { container } = render(() => <Banner variant="info" message="Info" />);
const banner = container.firstElementChild as HTMLElement;
expect(banner.getAttribute('role')).toBe('status');
expect(banner.getAttribute('aria-live')).toBe('polite');
});
it('forwards the data-testid prop', () => {
render(() => <Banner variant="info" message="Test" data-testid="custom-banner" />);
expect(screen.getByTestId('custom-banner')).toBeInTheDocument();
});
it('renders the close button when onClose is provided', () => {
const onClose = vi.fn();
render(() => (
<Banner variant="info" message="Test" onClose={onClose} data-testid="test-banner" />
));
const closeButton = screen.getByTestId('test-banner-close');
expect(closeButton).toBeInTheDocument();
expect(closeButton.getAttribute('aria-label')).toBe('Close notification');
});
it('does not render the close button when onClose is not provided', () => {
render(() => <Banner variant="info" message="Test" data-testid="test-banner" />);
expect(screen.queryByTestId('test-banner-close')).not.toBeInTheDocument();
});
it('calls onClose when the close button is clicked', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(() => (
<Banner variant="info" message="Test" onClose={onClose} data-testid="test-banner" />
));
const closeButton = screen.getByTestId('test-banner-close');
await user.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,264 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@solidjs/testing-library';
import userEvent from '@testing-library/user-event';
import { HashRouter, Route } from '@solidjs/router';
// We'll mock the stores at the module level and update them per test
const mockDashboardState = {
isUpdateAvailable: false,
newVersion: '',
announcementUrl: '',
canAutoUpdate: false,
processingUpdate: false,
processingVersion: false,
dnsVersion: '',
};
const mockEncryptionState = {
enabled: false,
not_after: '',
valid_cert: false,
};
vi.mock('panel/stores/dashboard', () => ({
get dashboardState() {
return mockDashboardState;
},
getUpdate: vi.fn(),
getVersion: vi.fn(),
}));
vi.mock('panel/stores/encryption', () => ({
get encryptionState() {
return mockEncryptionState;
},
}));
vi.mock('panel/common/intl', () => {
const intl = {
getMessage: (key: string, values?: any) => {
const messages: Record<string, string> = {
tls_certificate_expired: 'Your TLS certificate has expired',
tls_certificate_expiring: 'Your TLS certificate is about to expire',
update_available: `Version ${values?.version || ''} is available. Release notes`,
update_button: 'Update',
update_how_to: 'How to update',
version_number: `Version ${values?.value || ''}`,
check_updates_btn: 'Check for updates',
};
const msg = messages[key] || key;
if (values?.a) {
// When tag handlers are provided, return the full message
return `Version ${values.version} is available. Release notes`;
}
return msg;
},
getUILanguage: () => 'en',
changeLanguage: vi.fn(),
};
return { default: intl };
});
import { Banners } from 'panel/common/ui/Banners';
import { BANNER_TEST_VALUES } from 'panel/helpers/banners';
const renderBanners = (props?: { forceBanner?: any }) => {
return render(() => (
<HashRouter>
<Route path="/" component={() => <Banners {...props} />} />
</HashRouter>
));
};
const resetStores = () => {
mockDashboardState.isUpdateAvailable = false;
mockDashboardState.newVersion = '';
mockDashboardState.announcementUrl = '';
mockDashboardState.canAutoUpdate = false;
mockDashboardState.processingUpdate = false;
mockDashboardState.processingVersion = false;
mockDashboardState.dnsVersion = '';
mockEncryptionState.enabled = false;
mockEncryptionState.not_after = '';
mockEncryptionState.valid_cert = false;
};
describe('Banners', () => {
beforeEach(() => {
resetStores();
});
// ── Priority logic cases ──
it('shows TLS expired banner when cert is expired', () => {
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString(); // 1 day ago
renderBanners();
expect(screen.getByTestId('banner-tls-expired')).toBeInTheDocument();
expect(screen.getByText('Your TLS certificate has expired')).toBeInTheDocument();
});
it('shows TLS expiring banner when cert expires within 30 days', () => {
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = new Date(Date.now() + 15 * 86400000).toISOString(); // 15 days
renderBanners();
expect(screen.getByTestId('banner-tls-expiring')).toBeInTheDocument();
expect(screen.getByText('Your TLS certificate is about to expire')).toBeInTheDocument();
});
it('shows auto-update banner when update available and can auto-update', () => {
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = new Date(Date.now() + 60 * 86400000).toISOString(); // 60 days
mockDashboardState.isUpdateAvailable = true;
mockDashboardState.newVersion = 'v1.0.0';
mockDashboardState.canAutoUpdate = true;
renderBanners();
expect(screen.getByTestId('banner-update-auto')).toBeInTheDocument();
});
it('shows manual-update banner when update available but cannot auto-update', () => {
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = new Date(Date.now() + 60 * 86400000).toISOString();
mockDashboardState.isUpdateAvailable = true;
mockDashboardState.newVersion = 'v1.0.0';
mockDashboardState.canAutoUpdate = false;
renderBanners();
expect(screen.getByTestId('banner-update-manual')).toBeInTheDocument();
});
it('shows auto-update banner when TLS not enabled', () => {
mockDashboardState.isUpdateAvailable = true;
mockDashboardState.newVersion = 'v1.0.0';
mockDashboardState.canAutoUpdate = true;
renderBanners();
expect(screen.getByTestId('banner-update-auto')).toBeInTheDocument();
});
it('falls through to update when not_after is invalid (NaN)', () => {
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = 'not-a-date';
mockDashboardState.isUpdateAvailable = true;
mockDashboardState.newVersion = 'v1.0.0';
mockDashboardState.canAutoUpdate = true;
renderBanners();
expect(screen.getByTestId('banner-update-auto')).toBeInTheDocument();
});
it('renders nothing when no conditions met', () => {
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = new Date(Date.now() + 60 * 86400000).toISOString();
renderBanners();
expect(screen.queryByTestId('banner-root')).not.toBeInTheDocument();
});
it('TLS expired takes priority over update available', () => {
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString(); // expired
mockDashboardState.isUpdateAvailable = true;
mockDashboardState.newVersion = 'v1.0.0';
mockDashboardState.canAutoUpdate = true;
renderBanners();
// Only TLS expired banner should appear
expect(screen.getByTestId('banner-tls-expired')).toBeInTheDocument();
expect(screen.queryByTestId('banner-update-auto')).not.toBeInTheDocument();
});
// ── Dismiss cases ──
it('hides the banner after clicking the close button', async () => {
const user = userEvent.setup();
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString(); // expired
renderBanners();
expect(screen.getByTestId('banner-tls-expired')).toBeInTheDocument();
const closeButton = screen.getByTestId('banner-tls-expired-close');
await user.click(closeButton);
expect(screen.queryByTestId('banner-tls-expired')).not.toBeInTheDocument();
expect(screen.queryByTestId('banner-root')).not.toBeInTheDocument();
});
it('re-shows the banner when the underlying condition changes', async () => {
const user = userEvent.setup();
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString(); // expired
renderBanners();
// Dismiss the expired banner
const closeButton = screen.getByTestId('banner-tls-expired-close');
await user.click(closeButton);
expect(screen.queryByTestId('banner-tls-expired')).not.toBeInTheDocument();
// Change condition: now cert is valid but expiring soon
mockEncryptionState.not_after = new Date(Date.now() + 15 * 86400000).toISOString(); // 15 days
// Re-render
renderBanners();
// Should now show the expiring banner
expect(screen.getByTestId('banner-tls-expiring')).toBeInTheDocument();
expect(screen.queryByTestId('banner-tls-expired')).not.toBeInTheDocument();
});
// ── forceBanner (dev test override) ──
it('renders forced TLS expired banner regardless of store state', () => {
renderBanners({ forceBanner: BANNER_TEST_VALUES.tlsExpired });
expect(screen.getByTestId('banner-tls-expired')).toBeInTheDocument();
});
it('renders forced TLS expiring banner regardless of store state', () => {
renderBanners({ forceBanner: BANNER_TEST_VALUES.tlsExpiring });
expect(screen.getByTestId('banner-tls-expiring')).toBeInTheDocument();
});
it('renders forced auto-update banner', () => {
renderBanners({ forceBanner: BANNER_TEST_VALUES.updateAuto });
expect(screen.getByTestId('banner-update-auto')).toBeInTheDocument();
});
it('renders forced manual-update banner', () => {
renderBanners({ forceBanner: BANNER_TEST_VALUES.updateManual });
expect(screen.getByTestId('banner-update-manual')).toBeInTheDocument();
});
it('forceBanner overrides real banner conditions', () => {
// Set up a real TLS expired condition
mockEncryptionState.enabled = true;
mockEncryptionState.valid_cert = true;
mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString();
// But force auto-update banner instead
renderBanners({ forceBanner: BANNER_TEST_VALUES.updateAuto });
expect(screen.getByTestId('banner-update-auto')).toBeInTheDocument();
expect(screen.queryByTestId('banner-tls-expired')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@solidjs/testing-library';
import userEvent from '@testing-library/user-event';
const mockDashboardState = {
dnsVersion: '',
processingVersion: true,
theme: 'light',
language: 'en',
name: '',
};
vi.mock('panel/stores/dashboard', () => ({
get dashboardState() {
return mockDashboardState;
},
getVersion: vi.fn(),
changeTheme: vi.fn(),
changeLanguage: vi.fn(),
}));
vi.mock('panel/common/intl', () => {
const intl = {
getMessage: (key: string, values?: any) => {
const messages: Record<string, string> = {
privacy_policy: 'Privacy Policy',
report_an_issue: 'Report an issue',
release_notes: 'Release notes',
system_theme: 'System',
dark_theme: 'Dark',
light_theme: 'Light',
version_number: `Version ${values?.value || ''}`,
check_updates_btn: 'Check for updates',
};
return messages[key] || key;
},
getUILanguage: () => 'en',
changeLanguage: vi.fn(),
};
return { default: intl };
});
vi.mock('panel/lib/theme', () => ({
default: {
link: { link: 'linkClass', noDecoration: 'noDecorationClass' },
dropdown: {
menu: 'dropdownMenu',
item: 'dropdownItem',
item_active: 'dropdownItemActive',
},
},
}));
import { Footer } from 'panel/common/ui/Footer';
import { getVersion } from 'panel/stores/dashboard';
describe('Footer', () => {
beforeEach(() => {
vi.clearAllMocks();
mockDashboardState.dnsVersion = '';
mockDashboardState.processingVersion = true;
});
it('hides version badge when dnsVersion is empty', () => {
mockDashboardState.dnsVersion = '';
render(() => <Footer />);
expect(screen.queryByText(/Version/)).not.toBeInTheDocument();
});
it('shows version badge when dnsVersion is populated', () => {
mockDashboardState.dnsVersion = 'v1.0.0';
render(() => <Footer />);
expect(screen.getByText('Version v1.0.0')).toBeInTheDocument();
});
it('disables the check-updates button while processingVersion is true', () => {
mockDashboardState.dnsVersion = 'v1.0.0';
mockDashboardState.processingVersion = true;
render(() => <Footer />);
const button = screen.getByTestId('footer-check-updates');
expect(button).toBeDisabled();
});
it('enables the check-updates button when processingVersion is false', () => {
mockDashboardState.dnsVersion = 'v1.0.0';
mockDashboardState.processingVersion = false;
render(() => <Footer />);
const button = screen.getByTestId('footer-check-updates');
expect(button).not.toBeDisabled();
});
it('calls getVersion(true) when check-updates button is clicked', async () => {
const user = userEvent.setup();
mockDashboardState.dnsVersion = 'v1.0.0';
mockDashboardState.processingVersion = false;
render(() => <Footer />);
const button = screen.getByTestId('footer-check-updates');
await user.click(button);
expect(getVersion).toHaveBeenCalledWith(true);
});
it('has aria-label on the check-updates button', () => {
mockDashboardState.dnsVersion = 'v1.0.0';
render(() => <Footer />);
const button = screen.getByTestId('footer-check-updates');
expect(button.getAttribute('aria-label')).toBe('Check for updates');
});
});

View file

@ -7,17 +7,4 @@ describe('Toast', () => {
const { getByText } = render(() => <Toast id="1" message="hello" type="success" />);
expect(getByText('hello')).toBeTruthy();
});
it('renders interpolated options.components content', () => {
const { container } = render(() => (
<Toast
id="2"
message="update_failed"
type="notice"
options={{ components: { a: (c: string) => c } }}
/>
));
// Message rendered (not [object Object])
expect(container.textContent).not.toContain('[object Object]');
});
});

View file

@ -13,20 +13,13 @@ describe('toasts store', () => {
toastsState.notices.forEach((n: any) => removeToast(n.id));
});
it('addNoticeToast extracts error.toString() (no [object Object])', () => {
addNoticeToast({ error: 'update_failed' });
it('addNoticeToast stores the message', () => {
addNoticeToast('update_failed');
const last = toastsState.notices[toastsState.notices.length - 1];
expect(last.message).toBe('update_failed');
expect(last.type).toBe('notice');
});
it('addNoticeToast preserves options', () => {
const options = { components: { a: () => {} } };
addNoticeToast({ error: 'update_failed', options });
const last = toastsState.notices[toastsState.notices.length - 1];
expect(last.options).toEqual(options);
});
it('addErrorToast preserves options and action', () => {
const action = { text: 'retry', callback: () => {} };
addErrorToast({ error: 'boom', options: { x: 1 }, action });

View file

@ -119,6 +119,8 @@
--stroke-toplines-close-icon-hovered: var(--gray-10);
--stroke-toplines-close-icon-pressed: var(--gray-20);
--fills-backgrounds-recent-activity: var(--product-primary-90);
--fills-backgrounds-recent-activity-changed: var(--orange-90);
--fills-backgrounds-recent-activity-blocked: var(--red-90);
/* Switch */
--fills-switch-on-default: var(--product-primary-50);
--fills-switch-on-hovered: var(--product-primary-60);

View file

@ -119,6 +119,8 @@
--stroke-toplines-close-icon-hovered: var(--gray-10);
--stroke-toplines-close-icon-pressed: var(--gray-20);
--fills-backgrounds-recent-activity: var(--product-primary-10);
--fills-backgrounds-recent-activity-changed: var(--orange-10);
--fills-backgrounds-recent-activity-blocked: var(--red-10);
/* Switch */
--fills-switch-on-default: var(--product-primary-50);
--fills-switch-on-hovered: var(--product-primary-60);

View file

@ -0,0 +1,71 @@
.banner {
position: relative;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
padding: 16px 42px 16px 16px;
border-radius: 0;
width: 100%;
@media (min-width: 768px) {
padding: 8px 16px;
align-items: center;
flex-wrap: nowrap;
justify-content: center;
}
}
.action {
width: auto;
min-width: 110px;
max-width: max-content;
@media (min-width: 768px) {
max-width: none;
}
}
.close {
position: absolute;
top: 16px;
right: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
color: var(--default-gray-icons);
cursor: pointer;
border-radius: 4px;
flex-shrink: 0;
@media (min-width: 768px) {
position: static;
}
&:focus-visible {
outline: 2px solid var(--product-primary-90);
outline-offset: 2px;
}
}
.closeIcon {
width: 24px;
height: 24px;
}
.info {
background-color: var(--page-background-additional);
}
.warning {
background-color: var(--fills-backgrounds-recent-activity-changed);
}
.critical {
background-color: var(--fills-backgrounds-recent-activity-blocked);
}

View file

@ -0,0 +1,40 @@
import { type JSX } from 'solid-js';
import cn from 'clsx';
import { Icon } from 'panel/common/ui/Icon';
import s from './Banner.module.pcss';
import theme from 'panel/lib/theme';
export type BannerVariant = 'info' | 'warning' | 'critical';
type Props = {
variant: BannerVariant;
message: JSX.Element;
action?: JSX.Element;
onClose?: () => void;
'data-testid'?: string;
};
export const Banner = (props: Props) => (
<div
class={cn(s.banner, s[props.variant])}
role={props.variant === 'critical' ? 'alert' : 'status'}
aria-live={props.variant === 'critical' ? 'assertive' : 'polite'}
data-testid={props['data-testid']}
>
<div class={cn(s.message, theme.text.t3)}>{props.message}</div>
{props.action && <div class={s.action}>{props.action}</div>}
{props.onClose && (
<button
type="button"
class={s.close}
onClick={props.onClose}
aria-label="Close notification"
data-testid={`${props['data-testid']}-close`}
>
<Icon icon="cross" class={s.closeIcon} />
</button>
)}
</div>
);

View file

@ -0,0 +1,2 @@
export { Banner } from './Banner';
export type { BannerVariant } from './Banner';

View file

@ -0,0 +1,9 @@
.actionButton {
width: auto;
min-width: 110px;
}
.actionLink {
font-size: 14px;
white-space: nowrap;
}

View file

@ -0,0 +1,196 @@
import { Show, createMemo, createSignal, type JSX } from 'solid-js';
import { useNavigate, useSearchParams } from '@solidjs/router';
import { Banner } from 'panel/common/ui/Banner';
import { Button } from 'panel/common/ui/Button';
import { MANUAL_UPDATE_LINK } from 'panel/helpers/constants';
import { Paths } from 'panel/components/Routes/Paths';
import intl from 'panel/common/intl';
import theme from 'panel/lib/theme';
import { dashboardState, getUpdate } from 'panel/stores/dashboard';
import { encryptionState } from 'panel/stores/encryption';
import {
type BannerSpec,
type BannerType,
bannerSpecsEqual,
BANNER_TEST_VALUES,
} from 'panel/helpers/banners';
import s from './Banners.module.pcss';
const TLS_EXPIRY_WARNING_MS = 30 * 24 * 60 * 60 * 1000;
type Props = {
forceBanner?: BannerSpec;
};
export const Banners = (props: Props) => {
const navigate = useNavigate();
const [searchParams] = useSearchParams<{ forceBanner?: string }>();
const [dismissed, setDismissed] = createSignal<BannerSpec | null>(null);
const forceFromQuery = createMemo(() => {
const key = searchParams.forceBanner;
if (key && key in BANNER_TEST_VALUES) {
return BANNER_TEST_VALUES[key as BannerType];
}
return null;
});
const computeActiveBanner = (): BannerSpec | null => {
if (encryptionState.enabled && encryptionState.valid_cert && encryptionState.not_after) {
const expiry = new Date(encryptionState.not_after).getTime();
if (!Number.isNaN(expiry)) {
if (Date.now() > expiry) {
return { type: 'tls_expired' };
}
if (Date.now() > expiry - TLS_EXPIRY_WARNING_MS) {
return { type: 'tls_expiring' };
}
}
}
if (dashboardState.isUpdateAvailable) {
const spec = {
version: dashboardState.newVersion,
announcementUrl: dashboardState.announcementUrl,
};
return dashboardState.canAutoUpdate
? { type: 'update_auto', ...spec }
: { type: 'update_manual', ...spec };
}
return null;
};
const banner = createMemo<BannerSpec | null>(() => {
const active = props.forceBanner ?? forceFromQuery() ?? computeActiveBanner();
if (!active) return null;
const dismissedValue = dismissed();
if (dismissedValue && bannerSpecsEqual(dismissedValue, active)) {
return null;
}
return active;
});
const announcementLinkHandler = (announcementUrl: string) => (text: string) => (
<a href={announcementUrl} class={theme.link.link} target="_blank" rel="noopener noreferrer">
{text}
</a>
);
return (
<Show when={banner()}>
{(spec) => {
const current = spec();
const renderBanner = (): JSX.Element => {
switch (current.type) {
case 'tls_expired':
return (
<Banner
variant="critical"
message={intl.getMessage('tls_certificate_expired')}
action={
<Button
variant="secondary"
size="very-small"
compact
onClick={() => navigate(Paths.Encryption)}
class={s.actionButton}
>
{intl.getMessage('update_button')}
</Button>
}
onClose={() => setDismissed(current)}
data-testid="banner-tls-expired"
/>
);
case 'tls_expiring':
return (
<Banner
variant="warning"
message={intl.getMessage('tls_certificate_expiring')}
action={
<Button
variant="secondary"
size="very-small"
compact
onClick={() => navigate(Paths.Encryption)}
class={s.actionButton}
>
{intl.getMessage('update_button')}
</Button>
}
onClose={() => setDismissed(current)}
data-testid="banner-tls-expiring"
/>
);
case 'update_auto':
return (
<Banner
variant="info"
message={intl.getMessage('update_available', {
version: current.version,
a: announcementLinkHandler(current.announcementUrl),
})}
action={
<Button
variant="primary"
size="very-small"
compact
disabled={dashboardState.processingUpdate}
onClick={() => getUpdate()}
class={s.actionButton}
>
{intl.getMessage('update_button')}
</Button>
}
onClose={() => setDismissed(current)}
data-testid="banner-update-auto"
/>
);
case 'update_manual':
return (
<Banner
variant="info"
message={intl.getMessage('update_available', {
version: current.version,
a: announcementLinkHandler(current.announcementUrl),
})}
action={
<a
href={MANUAL_UPDATE_LINK}
target="_blank"
rel="noopener noreferrer"
class={s.actionLink}
>
<Button
variant="primary"
size="very-small"
compact
class={s.actionButton}
>
{intl.getMessage('update_how_to')}
</Button>
</a>
}
onClose={() => setDismissed(current)}
data-testid="banner-update-manual"
/>
);
default:
return null;
}
};
return <div data-testid="banner-root">{renderBanner()}</div>;
}}
</Show>
);
};

View file

@ -0,0 +1,3 @@
export { Banners } from './Banners';
export { BANNER_TEST_VALUES, getForceBannerFromQuery } from 'panel/helpers/banners';
export type { BannerSpec, BannerType } from 'panel/helpers/banners';

View file

@ -36,6 +36,11 @@
}
}
.height_xs {
height: 24px;
padding: 0 12px;
}
.height_s {
height: 48px;
}

View file

@ -1,53 +1,55 @@
import { type JSX, splitProps } from 'solid-js';
import { type JSX, splitProps, Show } from 'solid-js';
import cn from 'clsx';
import s from './Button.module.pcss';
export type ButtonProps = JSX.ButtonHTMLAttributes<HTMLButtonElement> & {
size?: 'small' | 'medium' | 'big';
size?: 'very-small' | 'small' | 'medium' | 'big';
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'secondary-danger';
leftAddon?: JSX.Element;
rightAddon?: JSX.Element;
compact?: boolean;
className?: string;
};
export const Button = (props: ButtonProps) => {
const [local, rest] = splitProps(props, [
'id',
'size',
'type',
'variant',
'children',
'size',
'class',
'className',
'onClick',
'children',
'disabled',
'leftAddon',
'rightAddon',
'compact',
]);
return (
<button
id={local.id}
type={local.type || 'button'}
{...rest}
type={props.type || 'button'}
disabled={local.disabled}
class={cn(
s.button,
s[local.variant || 'primary'],
{
[s.height_s]: (local.size || 'medium') === 'small',
[s.height_m]: (local.size || 'medium') === 'medium',
[s.height_l]: (local.size || 'medium') === 'big',
[s.height_xs]: local.size === 'very-small',
[s.height_s]: local.size === 'small',
[s.height_m]: local.size === 'medium',
[s.height_l]: local.size === 'big',
},
local.class,
local.className,
)}
onClick={(e) => (local.onClick as any)?.(e)}
disabled={local.disabled}
{...rest}
>
<div class={s.leftAddon}>{local.leftAddon}</div>
<Show when={local.leftAddon || !local.compact}>
<div class={s.leftAddon}>{local.leftAddon}</div>
</Show>
{local.children}
<div class={s.rightAddon}>{local.rightAddon}</div>
<Show when={local.rightAddon || !local.compact}>
<div class={s.rightAddon}>{local.rightAddon}</div>
</Show>
</button>
);
};

View file

@ -1,4 +1,4 @@
import { createSignal, createMemo, For } from 'solid-js';
import { createSignal, createMemo, For, Show } from 'solid-js';
import cn from 'clsx';
import theme from 'panel/lib/theme';
@ -11,7 +11,11 @@ import { LanguageDropdown } from '../LanguageDropdown/LanguageDropdown';
import { REPOSITORY, PRIVACY_POLICY_LINK, THEMES } from 'panel/helpers/constants';
import { LANGUAGES, LANGUAGE_NAMES } from 'panel/helpers/twosky';
import { setHtmlLangAttr, setUITheme } from 'panel/helpers/helpers';
import { changeTheme, changeLanguage as changeLanguageAction } from 'panel/stores/dashboard';
import {
changeTheme,
changeLanguage as changeLanguageAction,
getVersion,
} from 'panel/stores/dashboard';
import { dashboardState } from 'panel/stores/dashboard';
import s from './styles.module.pcss';
@ -70,71 +74,92 @@ export const Footer = () => {
return (
<footer class={s.footer}>
<div class={s.container}>
<div class={s.copyright}>&copy; 2018{getYear()} AdGuard Home</div>
<div class={s.leftGroup}>
<div class={s.copyright}>&copy; 2018{getYear()} AdGuard Home</div>
<div class={s.links}>
<For each={linksData()}>
{({ name, href }) => (
<a
href={href}
class={cn(theme.link.link, theme.link.noDecoration)}
target="_blank"
rel="noopener noreferrer"
<Show when={dashboardState.dnsVersion}>
<div class={s.version}>
{intl.getMessage('version_number', {
value: dashboardState.dnsVersion,
})}
<button
type="button"
class={cn(s.checkUpdateBtn, {
[s.checkUpdateBtn_loading]: dashboardState.processingVersion,
})}
aria-label={intl.getMessage('check_updates_btn')}
disabled={dashboardState.processingVersion}
data-testid="footer-check-updates"
onClick={() => getVersion(true)}
>
{name}
</a>
)}
</For>
</div>
<div class={s.column}>
<Dropdown
trigger="click"
open={themeDropdownOpen()}
onOpenChange={setThemeDropdownOpen}
menu={
<div class={theme.dropdown.menu}>
<For each={Object.values(THEMES)}>
{(v) => (
<button
type="button"
class={cn(theme.dropdown.item, {
[theme.dropdown.item_active]: currentTheme() === v,
})}
onClick={() => onThemeChange(v)}
>
{themeTranslations()[v]}
</button>
)}
</For>
</div>
}
class={s.dropdown}
position="bottomRight"
>
<div class={s.dropdownTrigger}>
<Icon icon={getThemeIcon() as any} class={s.icon} />
<span>
{
themeTranslations()[
isLoggedIn() ? currentTheme() : currentThemeLocal()
]
}
</span>
<Icon
icon={dashboardState.processingVersion ? 'loader' : 'refresh'}
/>
</button>
</div>
</Dropdown>
</Show>
<div class={s.links}>
<For each={linksData()}>
{({ name, href }) => (
<a
href={href}
class={cn(theme.link.link, theme.link.noDecoration)}
target="_blank"
rel="noopener noreferrer"
>
{name}
</a>
)}
</For>
</div>
</div>
<div class={s.column}>
<LanguageDropdown
value={currentLanguage()}
languages={LANGUAGES}
languageNames={LANGUAGE_NAMES}
onChange={(lang: string) => changeLanguage(lang as LocalesType)}
class={s.dropdown}
position="bottomRight"
/>
</div>
<Dropdown
trigger="click"
open={themeDropdownOpen()}
onOpenChange={setThemeDropdownOpen}
menu={
<div class={theme.dropdown.menu}>
<For each={Object.values(THEMES)}>
{(v) => (
<button
type="button"
class={cn(theme.dropdown.item, {
[theme.dropdown.item_active]: currentTheme() === v,
})}
onClick={() => onThemeChange(v)}
>
{themeTranslations()[v]}
</button>
)}
</For>
</div>
}
class={s.dropdown}
position="bottomRight"
>
<div class={s.dropdownTrigger}>
<Icon icon={getThemeIcon() as any} class={s.icon} />
<span>
{
themeTranslations()[
isLoggedIn() ? currentTheme() : currentThemeLocal()
]
}
</span>
</div>
</Dropdown>
<LanguageDropdown
value={currentLanguage()}
languages={LANGUAGES}
languageNames={LANGUAGE_NAMES}
onChange={(lang: string) => changeLanguage(lang as LocalesType)}
class={s.dropdown}
position="bottomRight"
/>
</div>
</footer>
);

View file

@ -25,6 +25,80 @@
}
}
.leftGroup {
display: flex;
align-items: center;
flex-direction: column;
gap: 24px;
justify-content: center;
@media (min-width: 1024px) {
column-gap: 40px;
row-gap: 24px;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
margin-right: auto;
}
}
.copyright {
order: 1;
@media (min-width: 1024px) {
order: 0;
}
}
.version {
display: flex;
align-items: center;
gap: 12px;
order: 2;
@media (min-width: 1024px) {
order: 0;
}
}
.checkUpdateBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
color: var(--default-product-icon);
flex-shrink: 0;
&:hover,
&:focus {
background-color: var(--page-background-additional);
}
&:focus-visible {
outline: 2px solid var(--product-primary-90);
outline-offset: 2px;
}
&:disabled {
cursor: default;
opacity: 0.5;
&:hover {
background-color: transparent;
}
}
}
.checkUpdateBtn_loading {
cursor: default;
}
.links {
display: flex;
flex-wrap: wrap;
@ -34,7 +108,7 @@
@media (min-width: 1024px) {
flex-wrap: nowrap;
gap: 24px;
margin-right: auto;
flex-shrink: 0;
}
@media (min-width: 1200px) {
@ -46,6 +120,8 @@
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
white-space: nowrap;
}
.dropdownTrigger {

View file

@ -5,6 +5,7 @@ import { Sidebar } from 'panel/common/ui/Sidebar';
import { Icons } from 'panel/common/ui/Icons';
import { Footer } from 'panel/common/ui/Footer';
import { Header } from 'panel/common/ui/Header';
import { Banners } from 'panel/common/ui/Banners';
import { Settings } from 'panel/components/Settings';
import intl, { LocalesType } from 'panel/common/intl';
import { Encryption } from 'panel/components/Encryption';
@ -103,6 +104,8 @@ const App = () => {
<>
<Header />
<Banners />
<div class={s.wrapper}>
<Sidebar />

View file

@ -82,7 +82,6 @@ export const Settings = () => {
const [searchParams] = useSearchParams<{ [SCROLL_QUERY_KEY]?: string }>();
// Handle query-param-based scroll to section
createEffect(() => {
if (!isLoading()) {
const section = searchParams[SCROLL_QUERY_KEY];

View file

@ -1,7 +1,6 @@
import { type JSX, onMount, onCleanup, Show } from 'solid-js';
import { Icon } from 'panel/common/ui/Icon';
import cn from 'clsx';
import intl from 'panel/common/intl';
import { getUndoCallback, clearUndoCallback } from 'panel/stores/toasts';
import { TOAST_TIMEOUTS } from '../../helpers/constants';
@ -23,9 +22,6 @@ type ToastProps = {
undoId?: string;
action?: ToastAction;
code?: string;
options?: {
components?: Record<string, (children: string) => JSX.Element>;
};
};
const Toast = (props: ToastProps) => {
@ -82,13 +78,6 @@ const Toast = (props: ToastProps) => {
removeCurrentToast();
};
const messageContent = () => {
if (props.options?.components) {
return intl.getMessage(String(props.message), props.options.components);
}
return props.message;
};
return (
<div
class={s.toast}
@ -104,7 +93,7 @@ const Toast = (props: ToastProps) => {
class={cn(s.icon, s[props.type])}
/>
<div class={s.content}>{messageContent()}</div>
<div class={s.content}>{props.message}</div>
</div>
<Show when={props.actionLabel}>

View file

@ -0,0 +1,62 @@
export type BannerSpec =
| { type: 'tls_expired' }
| { type: 'tls_expiring' }
| { type: 'update_auto'; version: string; announcementUrl: string }
| { type: 'update_manual'; version: string; announcementUrl: string };
/** Keys matching the query param values for each banner type. */
export type BannerType = 'tlsExpired' | 'tlsExpiring' | 'updateAuto' | 'updateManual';
/** Pre-built banner specs for visual QA. Use via `<Banners forceBanner={...}>` or `?forceBanner=updateAuto`. */
export const BANNER_TEST_VALUES: Record<BannerType, BannerSpec> = {
tlsExpired: { type: 'tls_expired' },
tlsExpiring: { type: 'tls_expiring' },
updateAuto: {
type: 'update_auto',
version: '1.0',
announcementUrl: 'https://github.com/AdguardTeam/AdGuardHome/releases',
},
updateManual: {
type: 'update_manual',
version: '1.0',
announcementUrl: 'https://github.com/AdguardTeam/AdGuardHome/releases',
},
};
/**
* Reads `?forceBanner=<key>` from the current URL query string.
* Returns the matching banner spec, or null if the param is absent or invalid.
*
* Example: `http://localhost:3000/?forceBanner=tlsExpired` `{ type: 'tls_expired' }`
*/
export const getForceBannerFromQuery = (): BannerSpec | null => {
// Check both query sources: before hash and inside hash fragment.
// HashRouter: http://localhost/#/dashboard?forceBanner=tlsExpired
const sources = [window.location.search, window.location.hash.split('?')[1]].filter(
Boolean,
) as string[];
for (const src of sources) {
const key = new URLSearchParams(src).get('forceBanner');
if (key && key in BANNER_TEST_VALUES) {
return BANNER_TEST_VALUES[key as BannerType];
}
}
return null;
};
/**
* Checks whether two banner specs represent the same logical banner.
* For TLS banners, only the type matters.
* For update banners, the version and announcement URL must also match.
*/
export const bannerSpecsEqual = (a: BannerSpec, b: BannerSpec | null): b is BannerSpec => {
if (!b) return false;
if (a.type !== b.type) return false;
if (a.type === 'update_auto' || a.type === 'update_manual') {
const update = b as typeof a;
return a.version === update.version && a.announcementUrl === update.announcementUrl;
}
return true;
};

View file

@ -3,10 +3,10 @@ import { untrack } from 'solid-js';
import { STANDARD_DNS_PORT, STANDARD_WEB_PORT } from 'panel/helpers/constants';
import { areEqualVersions } from 'panel/helpers/version';
import { apiClient } from 'panel/api/Api';
import { LocalesType } from 'panel/common/intl';
import intl, { LocalesType } from 'panel/common/intl';
import { addErrorToast, addSuccessToast, addNoticeToast } from './toasts';
import { getTlsStatus } from './encryption';
import { updateFailedNoticeOptions } from './dashboard/noticeOptions';
import { getUpdateFailedMessage } from './dashboard/noticeOptions';
import type { Client, AutoClient } from 'panel/initialState';
type DashboardState = {
@ -212,9 +212,9 @@ export const getVersion = async (recheck = false) => {
}
if (recheck) {
if (data && !areEqualVersions(currentVersion, data.new_version)) {
addSuccessToast('updates_checked');
addSuccessToast(intl.getMessage('updates_checked'));
} else {
addSuccessToast('updates_version_equal');
addSuccessToast(intl.getMessage('updates_version_equal'));
}
}
} catch {
@ -226,7 +226,7 @@ export const getVersion = async (recheck = false) => {
export const getUpdate = async () => {
setState('processingUpdate', true);
const handleRequestError = () => {
addNoticeToast({ error: 'update_failed', options: updateFailedNoticeOptions });
addNoticeToast(getUpdateFailedMessage());
setState('processingUpdate', false);
};
const handleRequestSuccess = (response: any) => {

View file

@ -1,15 +1,17 @@
import intl from 'panel/common/intl';
import { MANUAL_UPDATE_LINK } from 'panel/helpers/constants';
import theme from 'panel/lib/theme';
/**
* Options for the update_failed notice toast, providing a clickable
* hyperlink to the manual update instructions.
*/
export const updateFailedNoticeOptions = {
components: {
a: (children: string) => (
<a href={MANUAL_UPDATE_LINK} target="_blank" rel="noopener noreferrer">
{children}
export const getUpdateFailedMessage = () =>
intl.getMessage('update_failed', {
a: (text: string) => (
<a
href={MANUAL_UPDATE_LINK}
target="_blank"
rel="noopener noreferrer"
class={theme.link.link}
>
{text}
</a>
),
},
};
});

View file

@ -308,6 +308,7 @@ export const editFilter = async (url: string, data: any, whitelist: boolean) =>
try {
await apiClient.setFilterUrl({ url, data, whitelist });
setState({ processingConfigFilter: false, isModalOpen: false });
addSuccessToast(intl.getMessage('changes_saved_success'));
await getFilteringStatus();
} catch (error) {
addErrorToast({ error });

View file

@ -68,6 +68,7 @@ export const addRewrite = async (config: RewriteConfig) => {
await apiClient.addRewrite(config);
setState('processingAdd', false);
toggleRewritesModal();
addSuccessToast(intl.getMessage('changes_saved_success'));
await getRewritesList();
} catch (error) {
addErrorToast({ error });
@ -86,6 +87,7 @@ export const updateRewrite = async (
if (options.closeModal !== false) {
toggleRewritesModal();
}
addSuccessToast(intl.getMessage('changes_saved_success'));
await getRewritesList();
return true;
} catch (error) {

View file

@ -80,14 +80,12 @@ export const addSuccessToast = (message: any) => {
setState('notices', (prev) => [...prev, notice]);
};
export const addNoticeToast = (payload: { error: any; options?: any }) => {
const { error, options } = payload;
export const addNoticeToast = (message: any) => {
setState('notices', (prev) => [
...prev,
{
id: nanoid(),
message: error.toString(),
options,
message,
type: 'notice' as const,
},
]);