Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 54 additions & 10 deletions client_v2/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- [Technical Context](#technical-context)
- [Project Structure](#project-structure)
- [Build And Test Commands](#build-and-test-commands)
- [Fast verification loop](#fast-verification-loop)
- [Contribution Instructions](#contribution-instructions)
- [Code Guidelines](#code-guidelines)
- [System Design](#system-design)
Expand Down Expand Up @@ -86,7 +87,8 @@ client_v2/
│ │ └── styles/ # Theme tokens (vars.css, colors/light|dark|adg.css)
│ ├── components/ # Feature/page components (Dashboard, Clients, QueryLog, …)
│ │ ├── App/ # Root component — HashRouter + layout + <Route>s
│ │ └── Routes/Paths.ts # Route path constants
│ │ ├── Routes/Paths.ts # Route path constants
│ │ └── Stats/ # Dashboard "Show more" detail pages (top clients, domains, upstreams)
│ ├── helpers/ # Pure utilities, validators, theme helpers
│ ├── hooks/ # Shared SolidJS hooks
│ ├── lib/ # Theme aggregation, misc utils
Expand All @@ -113,7 +115,9 @@ client_v2/

# Build And Test Commands

All commands are run from the `client_v2/` directory.
All commands are run from the `client_v2/` directory. If your terminal's
working directory is the repo root (or anywhere else), `cd client_v2` first —
never run `npm`/`npx` from the repo root.

| Task | Command |
| ------------------------------------ | ------------------------------ |
Expand All @@ -125,6 +129,7 @@ All commands are run from the `client_v2/` directory.
| Lint | `npm run lint` |
| Lint + format fix | `npm run lint:fix` |
| Unit tests (single run) | `npm run test` |
| Unit tests (single file) | `npm run test -- <path>` |
| Unit tests (watch) | `npm run test:watch` |
| E2e tests | `npm run test:e2e` |
| E2e interactive UI | `npm run test:e2e:interactive` |
Expand All @@ -133,21 +138,56 @@ All commands are run from the `client_v2/` directory.
| Translation check | `npm run translations:check` |
| Full check (lint + typecheck + test) | `npm run check` |

> **Note**: `npm run test` runs the **entire** suite. While iterating, run a
> single file with `npm run test -- <path>`, optionally filtered to one
> test by name with `-t "<name>"` (a regex matching test names containing the
> pattern). Run `npm run check` only once, at the end — see "Fast
> verification loop" below.

### Fast verification loop

While iterating on a single component or test file, recheck quickly instead
of re-running the whole suite and typecheck for every change:

```bash
# If your terminal's working directory is the repo root, cd client_v2 first.
npm run typecheck > /tmp/tsc.log 2>&1; echo "tsc-exit:$?"
npm run test -- src/__tests__/stats-pages/stats-page.test.tsx > /tmp/vitest.log 2>&1; echo "vitest-exit:$?"

# Then inspect the logs, e.g.:
tail /tmp/tsc.log /tmp/vitest.log
```

The `> log 2>&1; echo "...-exit:$?"` pattern writes both streams to a file and
records the exit code, so a failing check is never mistaken for a hang and
there is no wall of output to scroll through. Replace the test path with the
file you are working on.

# Contribution Instructions

- You MUST verify your work with the linter, formatter, and type checker.

Use the following commands:
- `npm run typecheck` to check for type errors
Run only the npm scripts, from the `client_v2/` directory. Do not invoke
the underlying binaries directly (`npx eslint ...`, `npx tsc`,
`npx vitest`, `node_modules/.bin/...`, etc.) — the scripts are the only
supported commands. In particular, never run `npx` from the repo root:
the repository has no root `package.json`, so `npx` tries to download the
tooling from the npm registry and can hang for minutes. Ad-hoc `npx` calls
can also install tooling into the repo root instead of
`client_v2/node_modules`:
- `npm run check` — the full gate (lint + typecheck + unit tests)
- `npm run lint` to run the linter
- `npm run lint:fix` to fix linting and formatting issues automatically
- `npm run typecheck` to check for type errors
- `npm run test` to run the unit tests
- `npm run test -- <path> [-t "<name>"]` to run a single test file,
optionally filtered to one test by name — see "Fast verification loop"

- You MUST update the unit tests for changed code. New stores, helpers, and
components should have corresponding tests under `src/__tests__/`.

- You MUST run tests with `npm run test` to verify that your changes do not
break existing functionality. Use `npm run check` for the full gate
(lint + typecheck + test).
- You MUST run `npm run check` after completing your changes: lint,
typecheck, and all unit tests must pass before the task is done.

- You MUST verify UI changes against the running AdGuard Home instance, not
the webpack dev server. Rebuild with `npm run build-dev` (quick iteration)
Expand All @@ -157,9 +197,13 @@ All commands are run from the `client_v2/` directory.
default port `8080`; the dev server, when used, runs on `bind_port + 8000`.

- When you need to sign in to the running AdGuard Home instance to verify UI
changes in the browser, ask the user for the login and password with
`#tool:vscode/askQuestions` — do not guess credentials or reuse hardcoded
ones.
changes in the browser, read the credentials from `client_v2/.env.local`
(git-ignored; keys `ADGUARD_TEST_USERNAME` / `ADGUARD_TEST_PASSWORD` —
relative to `client_v2/` it is `.env.local`). Never echo the password into
the chat, terminal output, or log files, and never commit or copy the file
elsewhere. If the file is missing, ask the user for the login and password
with `#tool:vscode/askQuestions` — do not guess credentials or reuse
hardcoded ones.

- When making changes to the project structure, ensure the Project Structure
section in `AGENTS.md` is updated and remains valid.
Expand Down
19 changes: 17 additions & 2 deletions client_v2/src/__locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,6 @@
"protocols": "Protocols",
"queries": "Queries",
"queries_tooltip": "%value% queries",
"queries_total": "| %value% query total | %value% queries total",
"query_details": "Query details",
"query_log": "Query log",
"query_log_all_reasons": "All reasons",
Expand Down Expand Up @@ -578,7 +577,6 @@
"query_log_retention": "Log rotation",
"query_log_rewritten": "Rewritten",
"query_log_safe_search": "Safe search",
"query_log_strict_search": "Use double quotes for strict search",
"reason_table_header": "Reason",
"refresh_btn": "Refresh",
"refresh_statics": "Refresh statistics",
Expand All @@ -599,6 +597,7 @@
"reset_settings_confirm": "Yes, reset",
"response_details": "Response details",
"response_time": "Response time",
"avg_response_time": "Avg response time",
"result": "Result",
"resume_protection_timer": "Protection will resume in %time%",
"rewrite_add": "Add DNS rewrite",
Expand Down Expand Up @@ -746,15 +745,31 @@
"setup_ui_title": "Set the Web UI address for AdGuard Home",
"setup_ui_title_banner": "Web UI settings",
"show_blocked_responses": "Blocked",
"show_more": "Show more",
"show_more_count": "+ %count% more",
"show_processed_responses": "Processed",
"sort_asc": "Sort from A-Z",
"sort_by": "Sort by",
"sort_desc": "Sort from Z-A",
"sort_domain_asc": "Domain A-Z",
"sort_domain_desc": "Domain Z-A",
"sort_ip_asc": "Increasing IP address",
"sort_ip_desc": "Decreasing IP address",
"sort_name_asc": "Name A-Z",
"sort_name_desc": "Name Z-A",
"sort_queries_asc": "Increasing queries",
"sort_queries_desc": "Decreasing queries",
"sort_response_time_asc": "Increasing response time",
"sort_response_time_desc": "Decreasing response time",
"sort_upstream_asc": "Upstream A-Z",
"sort_upstream_desc": "Upstream Z-A",
"source_label": "Source",
"source_tooltip": "Source",
"stats_adult": "Blocked adult websites",
"stats_query_domain": "Top queried domains",
"stats_strict_search": "Use double quotes for exact matches",
"status_table_header": "Status",
"aria_actions": "Actions",
"subnet_error": "Range addresses must be in the same subnet",
"sunday": "Sunday",
"system_host_files": "System hosts files",
Expand Down
21 changes: 20 additions & 1 deletion client_v2/src/__tests__/app-routing.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { render, screen } from '@solidjs/testing-library';
import { render, screen, waitFor } from '@solidjs/testing-library';

// jsdom has no matchMedia; App's theme effect and some hooks depend on it.
beforeAll(() => {
Expand Down Expand Up @@ -51,6 +51,13 @@ vi.mock('panel/components/Toasts', () => ({ Toasts: (): null => null }));
vi.mock('panel/components/Dashboard', () => ({
Dashboard: () => <div data-testid="route-dashboard" />,
}));
vi.mock('panel/components/Stats', () => ({
TopClientsPage: () => <div data-testid="route-top-clients" />,
TopQueriedDomainsPage: () => <div data-testid="route-top-queried-domains" />,
TopBlockedDomainsPage: () => <div data-testid="route-top-blocked-domains" />,
TopUpstreamsPage: () => <div data-testid="route-top-upstreams" />,
UpstreamAvgTimePage: () => <div data-testid="route-upstream-avg-time" />,
}));

import App from '../components/App';

Expand All @@ -63,6 +70,18 @@ describe('App routing', () => {
expect(await screen.findByTestId('route-dashboard')).toBeInTheDocument();
});

it.each([
['#/top_clients', 'route-top-clients'],
['#/top_queried_domains', 'route-top-queried-domains'],
['#/top_blocked_domains', 'route-top-blocked-domains'],
['#/top_upstreams', 'route-top-upstreams'],
['#/upstream_avg_time', 'route-upstream-avg-time'],
])('renders the stats page for %s', async (hash, testid) => {
window.location.hash = hash;
render(() => <App />);
await waitFor(() => expect(screen.getByTestId(testid)).toBeInTheDocument());
});

it('mounts Banners between Header and the wrapper in the main entry', async () => {
window.location.hash = '#/dashboard';

Expand Down
101 changes: 101 additions & 0 deletions client_v2/src/__tests__/dashboard-show-more.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { type JSX } from 'solid-js';
import { render, screen } from '@solidjs/testing-library';
import { HashRouter, Route } from '@solidjs/router';
import { describe, it, expect } from 'vitest';

import { TopClients } from 'panel/components/Dashboard/blocks/TopClients';
import { TopQueriedDomains } from 'panel/components/Dashboard/blocks/TopQueriedDomains';
import { TopBlockedDomains } from 'panel/components/Dashboard/blocks/TopBlockedDomains';
import { TopUpstreams } from 'panel/components/Dashboard/blocks/TopUpstreams';
import { UpstreamAvgTime } from 'panel/components/Dashboard/blocks/UpstreamAvgTime';
import { DAY } from 'panel/helpers/constants';

const renderWithRouter = (ui: () => JSX.Element) =>
render(() => (
<HashRouter>
<Route path="/" component={ui} />
</HashRouter>
));

const getLinkHref = (testid: string) => screen.getByTestId(testid).getAttribute('href') ?? '';

describe('Dashboard "Show more" links', () => {
it('Top clients card links to /top_clients', () => {
renderWithRouter(() => (
<TopClients topClients={[{ name: '10.0.0.1', count: 5 }]} numDnsQueries={100} />
));
expect(screen.getByText('Show more')).toBeInTheDocument();
expect(getLinkHref('show-more-top-clients')).toContain('/top_clients');
});

it('Top queried domains card links to /top_queried_domains', () => {
renderWithRouter(() => (
<TopQueriedDomains
topQueriedDomains={[{ name: 'a.org', count: 5 }]}
numDnsQueries={100}
/>
));
expect(getLinkHref('show-more-top-queried-domains')).toContain('/top_queried_domains');

// The domain name is a QueryLog link filtered by the domain.
const domainLink = screen.getByText('a.org').closest('a');
expect(domainLink).not.toBeNull();
expect(domainLink!.getAttribute('href')).toContain('/logs');
expect(domainLink!.getAttribute('href')).toContain('a.org');
});

it('Top blocked domains card links to /top_blocked_domains', () => {
renderWithRouter(() => (
<TopBlockedDomains
topBlockedDomains={[{ name: 'a.org', count: 5 }]}
numBlockedFiltering={100}
/>
));
expect(getLinkHref('show-more-top-blocked-domains')).toContain('/top_blocked_domains');

const domainLink = screen.getByText('a.org').closest('a');
expect(domainLink).not.toBeNull();
expect(domainLink!.getAttribute('href')).toContain('/logs');
expect(domainLink!.getAttribute('href')).toContain('a.org');

// The blocked total links to QueryLog filtered by blocked status.
expect(getLinkHref('blocked-total-link')).toContain('/logs');
expect(getLinkHref('blocked-total-link')).toContain('status=blocked');
});

it('Top upstreams card links to /top_upstreams', () => {
renderWithRouter(() => (
<TopUpstreams
topUpstreamsResponses={[{ name: 'tls://x:853', count: 5 }]}
numDnsQueries={100}
/>
));
expect(getLinkHref('show-more-top-upstreams')).toContain('/top_upstreams');
});

it('Average upstream response time card links to /upstream_avg_time', () => {
renderWithRouter(() => (
<UpstreamAvgTime
topUpstreamsAvgTime={[{ name: 'tls://x:853', count: 12 }]}
avgProcessingTime={12}
/>
));
expect(getLinkHref('show-more-upstream-avg-time')).toContain('/upstream_avg_time');
});

it('the link is visible even when the card is empty', () => {
renderWithRouter(() => <TopQueriedDomains topQueriedDomains={[]} numDnsQueries={100} />);
expect(getLinkHref('show-more-top-queried-domains')).toContain('/top_queried_domains');
});

it('the link includes the selected stats period', () => {
renderWithRouter(() => (
<TopBlockedDomains
topBlockedDomains={[{ name: 'a.org', count: 5 }]}
numBlockedFiltering={100}
period={DAY}
/>
));
expect(getLinkHref('show-more-top-blocked-domains')).toContain(`period=${DAY}`);
});
});
84 changes: 84 additions & 0 deletions client_v2/src/__tests__/helpers/use-stats-refresh.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { onMount } from 'solid-js';
import { render } from '@solidjs/testing-library';
import { MemoryRouter, Route, createMemoryHistory } from '@solidjs/router';
import { describe, it, expect, vi, beforeEach } from 'vitest';

const mocks = vi.hoisted(() => {
const statsState = { interval: 86_400_000, configLoaded: true };

return {
statsState,
getStats: vi.fn(),
getStatsConfig: vi.fn(),
};
});

vi.mock('panel/stores/stats', () => ({
statsState: mocks.statsState,
getStats: mocks.getStats,
getStatsConfig: mocks.getStatsConfig,
}));

import { useStatsRefresh } from 'panel/components/Stats/hooks/useStatsRefresh';

function Harness(): null {
const refreshStats = useStatsRefresh();
onMount(() => {
void refreshStats();
});

return null;
}

const renderAt = (path: string) => {
const history = createMemoryHistory();
history.set({ value: path });

return render(() => (
<MemoryRouter history={history}>
<Route path="/" component={Harness} />
</MemoryRouter>
));
};

describe('useStatsRefresh', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mocks.statsState.configLoaded = true;
mocks.statsState.interval = 86_400_000;
mocks.getStatsConfig.mockResolvedValue(undefined);
});

it('calls getStats with the ?period URL param (clamped by the max interval)', () => {
renderAt('/?period=3600000');
expect(mocks.getStats).toHaveBeenCalledTimes(1);
expect(mocks.getStats).toHaveBeenCalledWith(3_600_000);
});

it('falls back to the default DAY period when there is no URL param', () => {
renderAt('/');
expect(mocks.getStats).toHaveBeenCalledTimes(1);
expect(mocks.getStats).toHaveBeenCalledWith(86_400_000);
});

it('loads the stats config before fetching stats when it is not loaded yet', async () => {
// Simulate a fresh page reload directly on a stats page: the config has
// not been fetched yet, so the store interval is still the default DAY.
mocks.statsState.configLoaded = false;
mocks.statsState.interval = 86_400_000;
mocks.getStatsConfig.mockImplementation(async () => {
// The server reports a 30-day retention interval.
mocks.statsState.configLoaded = true;
mocks.statsState.interval = 2_592_000_000;
});

renderAt('/?period=2592000000');

await vi.waitFor(() => {
expect(mocks.getStatsConfig).toHaveBeenCalledTimes(1);
});
// The URL period must not be clamped by the uninitialized DAY default.
expect(mocks.getStats).toHaveBeenCalledWith(2_592_000_000);
});
});
Loading
Loading