diff --git a/client_v2/AGENTS.md b/client_v2/AGENTS.md index 137abacc7bf..af63a211e17 100644 --- a/client_v2/AGENTS.md +++ b/client_v2/AGENTS.md @@ -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) @@ -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 + 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 @@ -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 | | ------------------------------------ | ------------------------------ | @@ -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 -- ` | | Unit tests (watch) | `npm run test:watch` | | E2e tests | `npm run test:e2e` | | E2e interactive UI | `npm run test:e2e:interactive` | @@ -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 -- `, optionally filtered to one +> test by name with `-t ""` (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 -- [-t ""]` 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) @@ -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. diff --git a/client_v2/src/__locales/en.json b/client_v2/src/__locales/en.json index 7391b41eb92..cd2af31154e 100644 --- a/client_v2/src/__locales/en.json +++ b/client_v2/src/__locales/en.json @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/client_v2/src/__tests__/app-routing.test.tsx b/client_v2/src/__tests__/app-routing.test.tsx index 04829ca8c57..e73589544fa 100644 --- a/client_v2/src/__tests__/app-routing.test.tsx +++ b/client_v2/src/__tests__/app-routing.test.tsx @@ -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(() => { @@ -51,6 +51,13 @@ vi.mock('panel/components/Toasts', () => ({ Toasts: (): null => null })); vi.mock('panel/components/Dashboard', () => ({ Dashboard: () =>
, })); +vi.mock('panel/components/Stats', () => ({ + TopClientsPage: () =>
, + TopQueriedDomainsPage: () =>
, + TopBlockedDomainsPage: () =>
, + TopUpstreamsPage: () =>
, + UpstreamAvgTimePage: () =>
, +})); import App from '../components/App'; @@ -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(() => ); + await waitFor(() => expect(screen.getByTestId(testid)).toBeInTheDocument()); + }); + it('mounts Banners between Header and the wrapper in the main entry', async () => { window.location.hash = '#/dashboard'; diff --git a/client_v2/src/__tests__/dashboard-show-more.test.tsx b/client_v2/src/__tests__/dashboard-show-more.test.tsx new file mode 100644 index 00000000000..c490c154373 --- /dev/null +++ b/client_v2/src/__tests__/dashboard-show-more.test.tsx @@ -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(() => ( + + + + )); + +const getLinkHref = (testid: string) => screen.getByTestId(testid).getAttribute('href') ?? ''; + +describe('Dashboard "Show more" links', () => { + it('Top clients card links to /top_clients', () => { + renderWithRouter(() => ( + + )); + 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(() => ( + + )); + 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(() => ( + + )); + 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(() => ( + + )); + expect(getLinkHref('show-more-top-upstreams')).toContain('/top_upstreams'); + }); + + it('Average upstream response time card links to /upstream_avg_time', () => { + renderWithRouter(() => ( + + )); + expect(getLinkHref('show-more-upstream-avg-time')).toContain('/upstream_avg_time'); + }); + + it('the link is visible even when the card is empty', () => { + renderWithRouter(() => ); + expect(getLinkHref('show-more-top-queried-domains')).toContain('/top_queried_domains'); + }); + + it('the link includes the selected stats period', () => { + renderWithRouter(() => ( + + )); + expect(getLinkHref('show-more-top-blocked-domains')).toContain(`period=${DAY}`); + }); +}); diff --git a/client_v2/src/__tests__/helpers/use-stats-refresh.test.tsx b/client_v2/src/__tests__/helpers/use-stats-refresh.test.tsx new file mode 100644 index 00000000000..6e6afb4fb3b --- /dev/null +++ b/client_v2/src/__tests__/helpers/use-stats-refresh.test.tsx @@ -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(() => ( + + + + )); +}; + +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); + }); +}); diff --git a/client_v2/src/__tests__/pagination.test.tsx b/client_v2/src/__tests__/pagination.test.tsx index 3c103b5f8ab..9bdc3f8e36a 100644 --- a/client_v2/src/__tests__/pagination.test.tsx +++ b/client_v2/src/__tests__/pagination.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { render, fireEvent } from '@solidjs/testing-library'; -import { createSignal } from 'solid-js'; +import { createSignal, For } from 'solid-js'; // CSS modules return {} under css:false, so theme class names would be // undefined. Mock theme with identity class names for deterministic asserts. @@ -17,11 +17,9 @@ vi.mock('panel/lib/theme', () => { 'arrow_left', 'arrow_right', 'summary', - 'dropdownText', - 'dropdownShowOnPage', + 'select', 'limitContainer', ]), - dropdown: make(['menu', 'item', 'item_active', 'icon', 'flexDropdownWrap', 'dropdown']), }, }; }); @@ -33,16 +31,21 @@ vi.mock('panel/common/intl', () => ({ }, })); -vi.mock('panel/common/ui/Dropdown', () => ({ - Dropdown: (props: any) => ( +vi.mock('panel/common/controls/Select', () => ({ + Select: (props: any) => ( <> - - {props.open &&
{props.menu}
} +
+ + {(option: any) => ( +
props.onChange(option)}> + {option.label} +
+ )} +
+
), })); @@ -164,4 +167,26 @@ describe('Pagination', () => { fireEvent.click(getByTestId('page-size-menu').querySelectorAll('[class~="item"]')[1]); expect(baseProps.onPageSizeChange).toHaveBeenCalledWith(20); }); + + it('scrolls to the top on page change when mobile', () => { + const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + const { container } = render(() => ( + + )); + fireEvent.click(pageButton(container, '2')); + expect(scrollSpy).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + scrollSpy.mockRestore(); + }); + + it('does not scroll to the top on page-size change when mobile', () => { + const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + const { getByTestId } = render(() => ( + + )); + fireEvent.click(getByTestId('page-size-trigger')); + fireEvent.click(getByTestId('page-size-menu').querySelectorAll('[class~="item"]')[1]); + expect(baseProps.onPageSizeChange).toHaveBeenCalledWith(20); + expect(scrollSpy).not.toHaveBeenCalled(); + scrollSpy.mockRestore(); + }); }); diff --git a/client_v2/src/__tests__/setup.ts b/client_v2/src/__tests__/setup.ts index b29c63ae581..0df0213343d 100644 --- a/client_v2/src/__tests__/setup.ts +++ b/client_v2/src/__tests__/setup.ts @@ -25,3 +25,25 @@ Object.defineProperty(window, 'matchMedia', { dispatchEvent: () => false, }) as MediaQueryList, }); + +// jsdom lacks ResizeObserver; floating-ui (@zag-js/popper) needs it when +// popovers/select menus open. +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +Object.defineProperty(window, 'ResizeObserver', { + writable: true, + value: ResizeObserverMock, +}); + +// jsdom lacks Element.scrollTo; @zag-js/select calls contentEl.scrollTo when +// a menu item is selected. +if (!Element.prototype.scrollTo) { + Object.defineProperty(Element.prototype, 'scrollTo', { + writable: true, + value: () => {}, + }); +} diff --git a/client_v2/src/__tests__/stats-pages/stats-page.test.tsx b/client_v2/src/__tests__/stats-pages/stats-page.test.tsx new file mode 100644 index 00000000000..dedd1b4d777 --- /dev/null +++ b/client_v2/src/__tests__/stats-pages/stats-page.test.tsx @@ -0,0 +1,233 @@ +import { render, screen, fireEvent, waitFor } from '@solidjs/testing-library'; +import { HashRouter, Route } from '@solidjs/router'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { StatsPage } from 'panel/components/Stats/StatsPage'; +import type { TableColumn } from 'panel/common/ui/Table'; +import { LocalStorageHelper } from 'panel/helpers/localStorageHelper'; +import type { IOption } from 'panel/lib/helpers/utils'; + +type Row = { name: string; count: number }; + +const rows: Row[] = [ + { name: 'a.org', count: 1 }, + { name: 'b.org', count: 2 }, + { name: 'c.org', count: 3 }, +]; + +const columns: TableColumn[] = [ + { key: 'name', header: { text: 'Domain' }, accessor: 'name', sortable: true }, + { + key: 'count', + header: { text: 'Queries' }, + accessor: 'count', + sortable: true, + }, +]; + +const mobileSortOptions: IOption[] = [ + { value: 'name:asc', label: 'Name asc' }, + { value: 'name:desc', label: 'Name desc' }, + { value: 'count:desc', label: 'Count desc' }, + { value: 'count:asc', label: 'Count asc' }, +]; + +const mockMatchMedia = (matches: boolean) => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => + ({ + matches, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) as MediaQueryList, + }); +}; + +const renderPage = (overrides: Partial>[0]> = {}) => + render(() => ( + + ( + + title="Top queried domains" + rows={rows} + columns={columns} + getRowId={(row) => row.name} + defaultSort={{ key: 'count', direction: 'desc' }} + loading={false} + emptyText="Nothing found" + onRefresh={vi.fn()} + searchTextForRow={(row) => row.name} + pageSizeKey="top_queried_domains_page_size" + sortStorageKey="top_queried_domains_sort" + mobileSortOptions={mobileSortOptions} + renderMobileCard={(row) => ( +
+ {row.name}: {row.count} +
+ )} + {...overrides} + /> + )} + /> +
+ )); + +const getRowsText = () => + Array.from(document.querySelectorAll('[class*="tableRow"]')).map((el) => el.textContent ?? ''); + +describe('StatsPage', () => { + beforeEach(() => { + mockMatchMedia(true); + localStorage.clear(); + window.location.hash = '#/'; + }); + + it('renders breadcrumb, title, search and refresh controls (desktop)', () => { + renderPage(); + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Top queried domains' })).toBeInTheDocument(); + expect(screen.getByTestId('stats-search-input')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument(); + }); + + it('sorts by defaultSort count desc and filters client-side on search', () => { + renderPage(); + const firstRowText = () => + Array.from(document.querySelectorAll('[class*="tableRow"]')) + .map((el) => el.textContent) + .join(' '); + + expect(firstRowText()).toContain('c.org'); + expect(firstRowText()).toContain('a.org'); + + fireEvent.input(screen.getByTestId('stats-search-input'), { + target: { value: 'b' }, + }); + expect(firstRowText()).toContain('b.org'); + expect(firstRowText()).not.toContain('a.org'); + }); + + it('renders mobile cards and filters them (mobile viewport)', () => { + mockMatchMedia(false); + renderPage(); + expect(screen.getAllByTestId('mobile-card').length).toBe(3); + + fireEvent.input(screen.getByTestId('stats-search-input'), { + target: { value: 'a' }, + }); + expect(screen.getAllByTestId('mobile-card').length).toBe(1); + expect(screen.getByTestId('mobile-card').textContent).toContain('a.org'); + }); + + it('renders children between the header and the table', () => { + renderPage({ children:
Add client
}); + expect(screen.getByTestId('below-header-content')).toBeInTheDocument(); + }); + + it('calls onRefresh when refresh is clicked', () => { + const onRefresh = vi.fn(); + renderPage({ onRefresh }); + fireEvent.click(screen.getByRole('button', { name: 'Refresh' })); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('shows the empty state text when nothing matches', () => { + renderPage(); + fireEvent.input(screen.getByTestId('stats-search-input'), { + target: { value: 'zzz' }, + }); + expect(screen.getByText('Nothing found')).toBeInTheDocument(); + }); + + it('uses the stored sort when no URL params are present', () => { + LocalStorageHelper.setItem('top_queried_domains_sort', { + key: 'name', + direction: 'asc', + }); + renderPage(); + + const rowsText = getRowsText(); + expect(rowsText[0]).toContain('a.org'); + expect(rowsText[2]).toContain('c.org'); + }); + + it('uses sort from URL params, overriding stored options', () => { + LocalStorageHelper.setItem('top_queried_domains_sort', { + key: 'name', + direction: 'asc', + }); + window.location.hash = '#/?sort=name&dir=desc'; + renderPage(); + + const rowsText = getRowsText(); + expect(rowsText[0]).toContain('c.org'); + expect(rowsText[2]).toContain('a.org'); + }); + + it('persists sort to localStorage and URL on header click', async () => { + // jsdom does not reflect history.replaceState in location.hash, so + // assert on the history call itself. + const replaceSpy = vi.spyOn(window.history, 'replaceState'); + + renderPage(); + fireEvent.click(screen.getByTestId('table-header-count')); + + expect(LocalStorageHelper.getItem('top_queried_domains_sort')).toEqual({ + key: 'count', + direction: 'asc', + }); + + // The router navigates inside a Solid transition, flushed in a microtask. + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + const calls = replaceSpy.mock.calls.map((call) => String(call[2])); + expect(calls.some((url) => url.includes('sort=count') && url.includes('dir=asc'))).toBe( + true, + ); + }); + + it('shows a loader instead of the empty state while loading on mobile', () => { + mockMatchMedia(false); + renderPage({ loading: true, rows: [] }); + + expect(screen.getByTestId('stats-mobile-loader')).toBeInTheDocument(); + expect(screen.queryByTestId('stats-empty-state')).not.toBeInTheDocument(); + }); + + it('shows the empty state once loading finishes with no rows on mobile', () => { + mockMatchMedia(false); + renderPage({ loading: false, rows: [] }); + + expect(screen.queryByTestId('stats-mobile-loader')).not.toBeInTheDocument(); + expect(screen.getByTestId('stats-empty-state')).toBeInTheDocument(); + expect(screen.getByText('Nothing found')).toBeInTheDocument(); + }); + + it('shows a loader on mobile during refresh even when rows are present', () => { + mockMatchMedia(false); + renderPage({ loading: true }); + + expect(screen.getByTestId('stats-mobile-loader')).toBeInTheDocument(); + expect(screen.queryAllByTestId('mobile-card')).toHaveLength(0); + }); + + it('shows the table loader instead of the empty state on desktop while loading', async () => { + mockMatchMedia(true); + renderPage({ loading: true, rows: [] }); + + await waitFor(() => { + expect(document.querySelector('[class*="tableLoader"]')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('stats-empty-state')).not.toBeInTheDocument(); + expect(screen.queryByTestId('stats-mobile-list')).not.toBeInTheDocument(); + }); +}); diff --git a/client_v2/src/common/controls/Input/Input.tsx b/client_v2/src/common/controls/Input/Input.tsx index 8ca2c4fa9c0..92939356cc7 100644 --- a/client_v2/src/common/controls/Input/Input.tsx +++ b/client_v2/src/common/controls/Input/Input.tsx @@ -11,6 +11,7 @@ type InputChangeEvent = Event & { }; type Props = Omit, 'size' | 'onChange' | 'onBlur'> & { + 'data-testid'?: string; label?: JSX.Element; class?: string; innerClass?: string; @@ -149,6 +150,7 @@ export const Input = (props: Props) => { maxLength={props.maxLength} disabled={props.disabled} autocomplete={props.autocomplete} + data-testid={props['data-testid']} />
diff --git a/client_v2/src/common/controls/Select/Select.pcss b/client_v2/src/common/controls/Select/Select.pcss index d6472876db4..254d3771035 100644 --- a/client_v2/src/common/controls/Select/Select.pcss +++ b/client_v2/src/common/controls/Select/Select.pcss @@ -108,6 +108,14 @@ } } + &.solid-select--height-extra-small { + [data-scope='select'][data-part='control'], + [data-scope='combobox'][data-part='control'] { + min-height: 32px; + font-size: 16px; + } + } + &.solid-select--height-small { [data-scope='select'][data-part='control'], [data-scope='combobox'][data-part='control'] { diff --git a/client_v2/src/common/controls/Select/Select.tsx b/client_v2/src/common/controls/Select/Select.tsx index 722e29a12dc..86183a8ba95 100644 --- a/client_v2/src/common/controls/Select/Select.tsx +++ b/client_v2/src/common/controls/Select/Select.tsx @@ -50,6 +50,7 @@ export const Select = < { 'solid-select--menu-right': props.menuPosition === 'right' }, { 'solid-select--borderless': props.borderless }, { 'solid-select--adaptive-height': props.adaptiveHeight }, + { 'solid-select--height-extra-small': props.height === 'extra-small' }, { 'solid-select--height-small': props.height === 'small' }, { 'solid-select--height-medium': props.height === 'medium' }, { 'solid-select--height-big': props.height === 'big' }, diff --git a/client_v2/src/common/controls/Select/types.ts b/client_v2/src/common/controls/Select/types.ts index e21da7f2171..ce68aa3e603 100644 --- a/client_v2/src/common/controls/Select/types.ts +++ b/client_v2/src/common/controls/Select/types.ts @@ -4,7 +4,7 @@ import { IOption } from 'panel/lib/helpers/utils'; export const SEARCH_ENABLE_LIMIT = 10; export type ISelectSize = 'auto' | 'small' | 'medium' | 'big' | 'big-limit' | 'responsive'; -export type ISelectHeight = 'small' | 'medium' | 'big' | 'big-mobile'; +export type ISelectHeight = 'extra-small' | 'small' | 'medium' | 'big' | 'big-mobile'; export type ISelectMenuSize = 'small' | 'medium' | 'big' | 'large'; export type ISelectValue = Multi extends true ? IOption[] : IOption; diff --git a/client_v2/src/common/intl/locales.generated.ts b/client_v2/src/common/intl/locales.generated.ts index a69c93b5cbe..5bb91aee738 100644 --- a/client_v2/src/common/intl/locales.generated.ts +++ b/client_v2/src/common/intl/locales.generated.ts @@ -8,82 +8,46 @@ export type LocaleMessage = Record; import en from 'panel/__locales/en.json'; export const LOCALE_LOADERS: Record Promise> = { - ar: () => import(/* webpackChunkName: "locale.ar" */ 'panel/__locales/ar.json'), - be: () => import(/* webpackChunkName: "locale.be" */ 'panel/__locales/be.json'), - bg: () => import(/* webpackChunkName: "locale.bg" */ 'panel/__locales/bg.json'), - cs: () => import(/* webpackChunkName: "locale.cs" */ 'panel/__locales/cs.json'), - da: () => import(/* webpackChunkName: "locale.da" */ 'panel/__locales/da.json'), - de: () => import(/* webpackChunkName: "locale.de" */ 'panel/__locales/de.json'), - es: () => import(/* webpackChunkName: "locale.es" */ 'panel/__locales/es.json'), - fa: () => import(/* webpackChunkName: "locale.fa" */ 'panel/__locales/fa.json'), - fi: () => import(/* webpackChunkName: "locale.fi" */ 'panel/__locales/fi.json'), - fr: () => import(/* webpackChunkName: "locale.fr" */ 'panel/__locales/fr.json'), - hr: () => import(/* webpackChunkName: "locale.hr" */ 'panel/__locales/hr.json'), - hu: () => import(/* webpackChunkName: "locale.hu" */ 'panel/__locales/hu.json'), - id: () => import(/* webpackChunkName: "locale.id" */ 'panel/__locales/id.json'), - it: () => import(/* webpackChunkName: "locale.it" */ 'panel/__locales/it.json'), - ja: () => import(/* webpackChunkName: "locale.ja" */ 'panel/__locales/ja.json'), - ko: () => import(/* webpackChunkName: "locale.ko" */ 'panel/__locales/ko.json'), - nl: () => import(/* webpackChunkName: "locale.nl" */ 'panel/__locales/nl.json'), - no: () => import(/* webpackChunkName: "locale.no" */ 'panel/__locales/no.json'), - pl: () => import(/* webpackChunkName: "locale.pl" */ 'panel/__locales/pl.json'), + 'ar': () => import(/* webpackChunkName: "locale.ar" */ 'panel/__locales/ar.json'), + 'be': () => import(/* webpackChunkName: "locale.be" */ 'panel/__locales/be.json'), + 'bg': () => import(/* webpackChunkName: "locale.bg" */ 'panel/__locales/bg.json'), + 'cs': () => import(/* webpackChunkName: "locale.cs" */ 'panel/__locales/cs.json'), + 'da': () => import(/* webpackChunkName: "locale.da" */ 'panel/__locales/da.json'), + 'de': () => import(/* webpackChunkName: "locale.de" */ 'panel/__locales/de.json'), + 'es': () => import(/* webpackChunkName: "locale.es" */ 'panel/__locales/es.json'), + 'fa': () => import(/* webpackChunkName: "locale.fa" */ 'panel/__locales/fa.json'), + 'fi': () => import(/* webpackChunkName: "locale.fi" */ 'panel/__locales/fi.json'), + 'fr': () => import(/* webpackChunkName: "locale.fr" */ 'panel/__locales/fr.json'), + 'hr': () => import(/* webpackChunkName: "locale.hr" */ 'panel/__locales/hr.json'), + 'hu': () => import(/* webpackChunkName: "locale.hu" */ 'panel/__locales/hu.json'), + 'id': () => import(/* webpackChunkName: "locale.id" */ 'panel/__locales/id.json'), + 'it': () => import(/* webpackChunkName: "locale.it" */ 'panel/__locales/it.json'), + 'ja': () => import(/* webpackChunkName: "locale.ja" */ 'panel/__locales/ja.json'), + 'ko': () => import(/* webpackChunkName: "locale.ko" */ 'panel/__locales/ko.json'), + 'nl': () => import(/* webpackChunkName: "locale.nl" */ 'panel/__locales/nl.json'), + 'no': () => import(/* webpackChunkName: "locale.no" */ 'panel/__locales/no.json'), + 'pl': () => import(/* webpackChunkName: "locale.pl" */ 'panel/__locales/pl.json'), 'pt-br': () => import(/* webpackChunkName: "locale.pt-br" */ 'panel/__locales/pt-br.json'), 'pt-pt': () => import(/* webpackChunkName: "locale.pt-pt" */ 'panel/__locales/pt-pt.json'), - ro: () => import(/* webpackChunkName: "locale.ro" */ 'panel/__locales/ro.json'), - ru: () => import(/* webpackChunkName: "locale.ru" */ 'panel/__locales/ru.json'), + 'ro': () => import(/* webpackChunkName: "locale.ro" */ 'panel/__locales/ro.json'), + 'ru': () => import(/* webpackChunkName: "locale.ru" */ 'panel/__locales/ru.json'), 'si-lk': () => import(/* webpackChunkName: "locale.si-lk" */ 'panel/__locales/si-lk.json'), - sk: () => import(/* webpackChunkName: "locale.sk" */ 'panel/__locales/sk.json'), - sl: () => import(/* webpackChunkName: "locale.sl" */ 'panel/__locales/sl.json'), + 'sk': () => import(/* webpackChunkName: "locale.sk" */ 'panel/__locales/sk.json'), + 'sl': () => import(/* webpackChunkName: "locale.sl" */ 'panel/__locales/sl.json'), 'sr-cs': () => import(/* webpackChunkName: "locale.sr-cs" */ 'panel/__locales/sr-cs.json'), - sv: () => import(/* webpackChunkName: "locale.sv" */ 'panel/__locales/sv.json'), - th: () => import(/* webpackChunkName: "locale.th" */ 'panel/__locales/th.json'), - tr: () => import(/* webpackChunkName: "locale.tr" */ 'panel/__locales/tr.json'), - uk: () => import(/* webpackChunkName: "locale.uk" */ 'panel/__locales/uk.json'), - vi: () => import(/* webpackChunkName: "locale.vi" */ 'panel/__locales/vi.json'), + 'sv': () => import(/* webpackChunkName: "locale.sv" */ 'panel/__locales/sv.json'), + 'th': () => import(/* webpackChunkName: "locale.th" */ 'panel/__locales/th.json'), + 'tr': () => import(/* webpackChunkName: "locale.tr" */ 'panel/__locales/tr.json'), + 'uk': () => import(/* webpackChunkName: "locale.uk" */ 'panel/__locales/uk.json'), + 'vi': () => import(/* webpackChunkName: "locale.vi" */ 'panel/__locales/vi.json'), 'zh-cn': () => import(/* webpackChunkName: "locale.zh-cn" */ 'panel/__locales/zh-cn.json'), 'zh-hk': () => import(/* webpackChunkName: "locale.zh-hk" */ 'panel/__locales/zh-hk.json'), - 'zh-tw': () => import(/* webpackChunkName: "locale.zh-tw" */ 'panel/__locales/zh-tw.json'), + 'zh-tw': () => import(/* webpackChunkName: "locale.zh-tw" */ 'panel/__locales/zh-tw.json') }; -export const LOCALE_CODES = new Set([ - 'ar', - 'be', - 'bg', - 'cs', - 'da', - 'de', - 'en', - 'es', - 'fa', - 'fi', - 'fr', - 'hr', - 'hu', - 'id', - 'it', - 'ja', - 'ko', - 'nl', - 'no', - 'pl', - 'pt-br', - 'pt-pt', - 'ro', - 'ru', - 'si-lk', - 'sk', - 'sl', - 'sr-cs', - 'sv', - 'th', - 'tr', - 'uk', - 'vi', - 'zh-cn', - 'zh-hk', - 'zh-tw', -]); +export const LOCALE_CODES = new Set(['ar', 'be', 'bg', 'cs', 'da', 'de', 'en', 'es', 'fa', 'fi', 'fr', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'nl', 'no', 'pl', 'pt-br', 'pt-pt', 'ro', 'ru', 'si-lk', 'sk', 'sl', 'sr-cs', 'sv', 'th', 'tr', 'uk', 'vi', 'zh-cn', 'zh-hk', 'zh-tw']); export const LOCALES: Record = { en: en, }; + diff --git a/client_v2/src/common/ui/Breadcrumbs/Breadcrumbs.tsx b/client_v2/src/common/ui/Breadcrumbs/Breadcrumbs.tsx index 25eb8bfd8ff..204ee7905f3 100644 --- a/client_v2/src/common/ui/Breadcrumbs/Breadcrumbs.tsx +++ b/client_v2/src/common/ui/Breadcrumbs/Breadcrumbs.tsx @@ -11,6 +11,7 @@ export type BreadcrumbLink = { path: RoutePathKey; title: string; props?: Partial>; + dataTestid?: string; }; type Props = { @@ -21,7 +22,7 @@ type Props = { export const Breadcrumbs = (props: Props) => (
- {({ path, title, props: linkProps }) => ( + {({ path, title, props: linkProps, dataTestid }) => (
( theme.common.textOverflow, )} props={linkProps} + data-testid={dataTestid} > {title} diff --git a/client_v2/src/common/ui/ClientBlockConfirm/ClientBlockConfirm.tsx b/client_v2/src/common/ui/ClientBlockConfirm/ClientBlockConfirm.tsx new file mode 100644 index 00000000000..a75fa1b94a1 --- /dev/null +++ b/client_v2/src/common/ui/ClientBlockConfirm/ClientBlockConfirm.tsx @@ -0,0 +1,105 @@ +import { Show, createMemo, createSignal } from 'solid-js'; + +import intl from 'panel/common/intl'; +import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog'; +import { accessState, toggleClientBlock } from 'panel/stores/access'; +import { addErrorToast } from 'panel/stores/toasts'; + +export type ClientBlockAction = 'block' | 'unblock'; + +export type ClientBlockConfirmState = { + open: boolean; + client: string; + action: ClientBlockAction; +}; + +export const initialClientBlockConfirmState: ClientBlockConfirmState = { + open: false, + client: '', + action: 'block', +}; + +export const CLIENT_BLOCK_CONFIRM_SUBMIT_TEST_ID = 'confirm-dialog-submit'; + +type ClientBlockConfirmDialogProps = { + state: ClientBlockConfirmState; + onClose: () => void; + onConfirm: () => void; +}; + +export const ClientBlockConfirmDialog = (props: ClientBlockConfirmDialogProps) => { + const isBlock = () => props.state.action === 'block'; + + const title = createMemo(() => + isBlock() + ? intl.getMessage('confirm_client_block_title', { ip: props.state.client }) + : intl.getMessage('confirm_client_unblock_title', { ip: props.state.client }), + ); + + const text = createMemo(() => + isBlock() + ? intl.getMessage('confirm_client_block_desc', { ip: props.state.client }) + : intl.getMessage('confirm_client_unblock_desc', { ip: props.state.client }), + ); + + return ( + + + + ); +}; + +/** + * State and actions for the client block confirm dialog: blocked status + * checks, opening/closing the dialog and applying the block/unblock action + * to the access list. + */ +export const useClientBlockConfirm = () => { + const [confirmState, setConfirmState] = createSignal( + initialClientBlockConfirmState, + ); + + const isClientBlocked = (clientName: string) => { + const str = accessState.disallowed_clients || ''; + return str ? str.split('\n').filter(Boolean).includes(clientName) : false; + }; + + const openConfirmDialog = (client: string, action: ClientBlockAction) => + setConfirmState({ open: true, client, action }); + + const closeConfirmDialog = () => setConfirmState(initialClientBlockConfirmState); + + const handleConfirm = async () => { + const { client, action } = confirmState(); + if (action === 'block') { + if (isClientBlocked(client)) { + addErrorToast({ + error: intl.getMessage('client_already_blocked', { ip: client }), + }); + closeConfirmDialog(); + return; + } + await toggleClientBlock(client, false, ''); + } else { + await toggleClientBlock(client, true, client); + } + closeConfirmDialog(); + }; + + return { + confirmState, + isClientBlocked, + openConfirmDialog, + closeConfirmDialog, + handleConfirm, + }; +}; diff --git a/client_v2/src/common/ui/ClientBlockConfirm/index.ts b/client_v2/src/common/ui/ClientBlockConfirm/index.ts new file mode 100644 index 00000000000..52cb63ed831 --- /dev/null +++ b/client_v2/src/common/ui/ClientBlockConfirm/index.ts @@ -0,0 +1,7 @@ +export { + ClientBlockConfirmDialog, + useClientBlockConfirm, + CLIENT_BLOCK_CONFIRM_SUBMIT_TEST_ID, + initialClientBlockConfirmState, +} from './ClientBlockConfirm'; +export type { ClientBlockAction, ClientBlockConfirmState } from './ClientBlockConfirm'; diff --git a/client_v2/src/common/ui/Link/Link.tsx b/client_v2/src/common/ui/Link/Link.tsx index fdf7e828c40..f4fa37997e4 100644 --- a/client_v2/src/common/ui/Link/Link.tsx +++ b/client_v2/src/common/ui/Link/Link.tsx @@ -11,6 +11,7 @@ import { import theme from 'panel/lib/theme'; type Props = { + 'data-testid'?: string; to: RoutePathKey; hash?: string; props?: LinkParams; @@ -56,7 +57,13 @@ export const Link = (linkProps: Props) => { +
{linkProps.children}
} @@ -72,6 +79,7 @@ export const Link = (linkProps: Props) => { linkProps.hash, )} onClick={handleClick} + data-testid={linkProps['data-testid']} > {linkProps.children} diff --git a/client_v2/src/common/ui/Table/Table.module.pcss b/client_v2/src/common/ui/Table/Table.module.pcss index 6072e4facb1..eba7744fe1b 100644 --- a/client_v2/src/common/ui/Table/Table.module.pcss +++ b/client_v2/src/common/ui/Table/Table.module.pcss @@ -12,6 +12,9 @@ } .table { + --table-row-min-height: 42px; + --table-header-height: 50px; + display: flex; flex-direction: column; gap: 16px; @@ -21,6 +24,12 @@ } } +.tableWithPagination { + @media (min-width: 768px) { + min-height: calc(var(--table-row-min-height) * 10 + var(--table-header-height)); + } +} + .tableHeader { display: none; @@ -51,7 +60,7 @@ .tableHeaderCell { @media (min-width: 768px) { - min-height: 42px; + min-height: var(--table-row-min-height); padding: 8px 12px; overflow: hidden; } @@ -66,7 +75,7 @@ .tableBodyCell { @media (min-width: 768px) { - min-height: 42px; + min-height: var(--table-row-min-height); padding: 8px 12px; align-items: center; } @@ -108,7 +117,9 @@ .sortIcon { flex-shrink: 0; opacity: 0; - transition: opacity var(--t2), transform var(--t2); + transition: + opacity var(--t2), + transform var(--t2); } .sortAsc { @@ -125,7 +136,7 @@ } .tablePagination { - margin-top: auto; + margin-top: 0; } .emptyTableWrapper { diff --git a/client_v2/src/common/ui/Table/Table.tsx b/client_v2/src/common/ui/Table/Table.tsx index 745bfa46f8b..a28c4a3c26d 100644 --- a/client_v2/src/common/ui/Table/Table.tsx +++ b/client_v2/src/common/ui/Table/Table.tsx @@ -10,7 +10,7 @@ import s from './Table.module.pcss'; import { Icon } from '../Icon'; -const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50]; +export const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50]; export const DEFAULT_PAGE_SIZE = DEFAULT_PAGE_SIZE_OPTIONS[0]; export interface TableColumn { @@ -195,6 +195,11 @@ export const Table = >(props: TableProps) => { const hasData = () => paginatedData().length > 0; + // The rows-per-page select lives in the pagination footer, so the whole + // footer is hidden until there is at least a full page of rows. + const showPagination = () => + (props.pagination ?? true) && sortedData().length >= DEFAULT_PAGE_SIZE; + return ( >(props: TableProps) => { >
-
+
{(column) => ( @@ -299,21 +308,21 @@ export const Table = >(props: TableProps) => {
{props.emptyTable}
-
- = DEFAULT_PAGE_SIZE}> -
- -
-
+ +
+ +
+
+
); diff --git a/client_v2/src/common/ui/Table/blocks/Pagination/Pagination.tsx b/client_v2/src/common/ui/Table/blocks/Pagination/Pagination.tsx index 4e5ab04df93..63aba3ec74b 100644 --- a/client_v2/src/common/ui/Table/blocks/Pagination/Pagination.tsx +++ b/client_v2/src/common/ui/Table/blocks/Pagination/Pagination.tsx @@ -1,10 +1,12 @@ -import { createSignal, createMemo, For } from 'solid-js'; +import { createMemo, For } from 'solid-js'; import cn from 'clsx'; import { Icon } from 'panel/common/ui/Icon'; import theme from 'panel/lib/theme'; -import { Dropdown } from 'panel/common/ui/Dropdown'; +import { Select } from 'panel/common/controls/Select'; import intl from 'panel/common/intl'; +import type { IOption } from 'panel/lib/helpers/utils'; +import { useIsMobile } from 'panel/hooks/useIsMobile'; type Props = { currentPage: number; @@ -14,7 +16,6 @@ type Props = { pageSizeOptions: number[]; onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; - limitButtonDescription?: string; }; export const generatePageNumbers = ( @@ -56,33 +57,34 @@ export const generatePageNumbers = ( }; export const Pagination = (props: Props) => { - const [limitMenuOpen, setLimitMenuOpen] = createSignal(false); - const canPreviousPage = () => props.currentPage > 0; const canNextPage = () => props.currentPage < props.totalPages - 1; const pageNumbers = createMemo(() => generatePageNumbers(props.currentPage, props.totalPages)); + const isMobile = useIsMobile(); - const limitMenu = ( -
- - {(size) => ( -
{ - props.onPageSizeChange(size); - setLimitMenuOpen(false); - }} - > - {intl.getMessage('rows_per_page', { value: size })} -
- )} -
-
+ const pageSizeOptions = createMemo[]>(() => + props.pageSizeOptions.map((size) => ({ + value: size, + label: intl.getMessage('rows_per_page', { value: size }), + })), ); + const scrollToTopOnMobile = () => { + if (isMobile()) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + }; + + const handlePageChange = (page: number) => { + props.onPageChange(page); + scrollToTopOnMobile(); + }; + + const handlePageSizeChange = (size: number) => { + props.onPageSizeChange(size); + }; + const renderPages = () => { if (props.totalPages <= 1) { return null; @@ -93,9 +95,10 @@ export const Pagination = (props: Props) => {
); diff --git a/client_v2/src/components/App/index.tsx b/client_v2/src/components/App/index.tsx index b8224bf6fc7..be4213598e8 100644 --- a/client_v2/src/components/App/index.tsx +++ b/client_v2/src/components/App/index.tsx @@ -36,6 +36,13 @@ 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'; +import { + TopClientsPage, + TopQueriedDomainsPage, + TopBlockedDomainsPage, + TopUpstreamsPage, + UpstreamAvgTimePage, +} from '../Stats'; const SetupGuideRoute = () => ; const BlockedServicesRoute = () => ; @@ -127,6 +134,11 @@ const App = () => { )} > + + + + + diff --git a/client_v2/src/components/Dashboard/Dashboard.tsx b/client_v2/src/components/Dashboard/Dashboard.tsx index 649037a3f18..3e9a08854e7 100644 --- a/client_v2/src/components/Dashboard/Dashboard.tsx +++ b/client_v2/src/components/Dashboard/Dashboard.tsx @@ -5,6 +5,7 @@ import { PageLoader } from 'panel/common/ui/Loader'; import { dashboardState, toggleProtection, getClients } from 'panel/stores/dashboard'; import { statsState, getStats, getStatsConfig, enableStatistics } from 'panel/stores/stats'; import { accessState, getAccessList } from 'panel/stores/access'; +import { getStoredStatsPeriod } from 'panel/helpers/statistics'; import { LocalStorageHelper, LOCAL_STORAGE_KEYS } from 'panel/helpers/localStorageHelper'; import { ONE_SECOND_IN_MS, HOUR, DAY, STATS_INTERVALS_DAYS } from 'panel/helpers/constants'; @@ -20,16 +21,9 @@ import { UpstreamAvgTime } from './blocks/UpstreamAvgTime'; import s from './Dashboard.module.pcss'; -const getSavedPeriod = (): number => { - const savedPeriod = LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.STATS_PERIOD); - return typeof savedPeriod === 'number' && Number.isFinite(savedPeriod) && savedPeriod > 0 - ? savedPeriod - : DAY; -}; - export const Dashboard = () => { const [remainingTime, setRemainingTime] = createSignal(null); - const [selectedPeriod, setSelectedPeriod] = createSignal(getSavedPeriod()); + const [selectedPeriod, setSelectedPeriod] = createSignal(getStoredStatsPeriod()); let timerRef: ReturnType | null = null; const startCountdown = (duration: number) => { @@ -200,26 +194,31 @@ export const Dashboard = () => {
diff --git a/client_v2/src/components/Dashboard/blocks/CardFooter/CardFooter.module.pcss b/client_v2/src/components/Dashboard/blocks/CardFooter/CardFooter.module.pcss new file mode 100644 index 00000000000..e837cfe8d70 --- /dev/null +++ b/client_v2/src/components/Dashboard/blocks/CardFooter/CardFooter.module.pcss @@ -0,0 +1,13 @@ +.cardFooter { + display: flex; + flex-direction: column; + text-align: center; + padding: 16px; + border-top: 1px solid var(--default-item-divider); + + @media (min-width: 1024px) { + padding: 8px 16px; + flex-direction: row; + border: 0; + } +} diff --git a/client_v2/src/components/Dashboard/blocks/CardFooter/CardFooter.tsx b/client_v2/src/components/Dashboard/blocks/CardFooter/CardFooter.tsx new file mode 100644 index 00000000000..dd276bb14a0 --- /dev/null +++ b/client_v2/src/components/Dashboard/blocks/CardFooter/CardFooter.tsx @@ -0,0 +1,27 @@ +import cn from 'clsx'; + +import intl from 'panel/common/intl'; +import theme from 'panel/lib/theme'; +import { Link } from 'panel/common/ui/Link'; +import type { QueryParams, RoutePathKey } from 'panel/components/Routes/Paths'; + +import s from './CardFooter.module.pcss'; + +type Props = { + to: RoutePathKey; + testId: string; + query?: QueryParams; +}; + +export const CardFooter = (props: Props) => ( +
+ + {intl.getMessage('show_more')} + +
+); diff --git a/client_v2/src/components/Dashboard/blocks/CardFooter/index.ts b/client_v2/src/components/Dashboard/blocks/CardFooter/index.ts new file mode 100644 index 00000000000..9df64b5f489 --- /dev/null +++ b/client_v2/src/components/Dashboard/blocks/CardFooter/index.ts @@ -0,0 +1 @@ +export { CardFooter } from './CardFooter'; diff --git a/client_v2/src/components/Dashboard/blocks/GeneralStatistics/GeneralStatistics.module.pcss b/client_v2/src/components/Dashboard/blocks/GeneralStatistics/GeneralStatistics.module.pcss index 16ab7629b69..3dfe145bc65 100644 --- a/client_v2/src/components/Dashboard/blocks/GeneralStatistics/GeneralStatistics.module.pcss +++ b/client_v2/src/components/Dashboard/blocks/GeneralStatistics/GeneralStatistics.module.pcss @@ -7,7 +7,7 @@ overflow: hidden; @media (min-width: 768px) { - height: 374px; + height: 384px; } } @@ -20,7 +20,6 @@ .tableRows { height: 100%; - overflow-y: auto; } .rowDivider { diff --git a/client_v2/src/components/Dashboard/blocks/GeneralStatistics/GeneralStatistics.tsx b/client_v2/src/components/Dashboard/blocks/GeneralStatistics/GeneralStatistics.tsx index a979e7b8579..4ce3596e193 100644 --- a/client_v2/src/components/Dashboard/blocks/GeneralStatistics/GeneralStatistics.tsx +++ b/client_v2/src/components/Dashboard/blocks/GeneralStatistics/GeneralStatistics.tsx @@ -7,7 +7,6 @@ import { EmptyState } from '../EmptyState'; import s from './GeneralStatistics.module.pcss'; import { StatRow } from '../StatRow'; -import { formatCompactNumber } from 'panel/helpers/helpers'; import { RoutePath } from 'panel/components/Routes/Paths'; import { QUERY_LOG_REASON_FILTER } from 'panel/helpers/constants'; @@ -38,14 +37,6 @@ export const GeneralStatistics = (props: Props) => {
{intl.getMessage('general_statistics')}
- - -
- {intl.getPlural('queries_total', props.numDnsQueries, { - value: formatCompactNumber(props.numDnsQueries), - })} -
-
}> diff --git a/client_v2/src/components/Dashboard/blocks/Header/Header.tsx b/client_v2/src/components/Dashboard/blocks/Header/Header.tsx index ada6fac2e6a..4acc78273a7 100644 --- a/client_v2/src/components/Dashboard/blocks/Header/Header.tsx +++ b/client_v2/src/components/Dashboard/blocks/Header/Header.tsx @@ -229,10 +229,10 @@ export const Header = (props: Props) => {
-
+
@@ -88,7 +98,18 @@ export const TopBlockedDomains = (props: Props) => { - {domain.name} + + {domain.name} +
@@ -146,6 +167,12 @@ export const TopBlockedDomains = (props: Props) => {
+ +
); }; diff --git a/client_v2/src/components/Dashboard/blocks/TopClients/TopClients.module.pcss b/client_v2/src/components/Dashboard/blocks/TopClients/TopClients.module.pcss index 0209c7a38c8..9bd8e36e0fa 100644 --- a/client_v2/src/components/Dashboard/blocks/TopClients/TopClients.module.pcss +++ b/client_v2/src/components/Dashboard/blocks/TopClients/TopClients.module.pcss @@ -2,13 +2,13 @@ display: flex; flex-direction: column; border-radius: 16px; - padding: 8px 0 0; + padding: 8px 0; border: 1px solid var(--default-item-divider); overflow: hidden; @media (min-width: 768px) { max-height: 384px; - padding: 8px 8px 0; + padding: 8px; } } @@ -30,7 +30,6 @@ .tableRows { height: 100%; - overflow-y: auto; } .tableRowRight { @@ -201,6 +200,13 @@ display: flex; align-items: center; gap: 8px; + min-width: 0; +} + +.clientIpText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .clientIpLink { diff --git a/client_v2/src/components/Dashboard/blocks/TopClients/TopClients.tsx b/client_v2/src/components/Dashboard/blocks/TopClients/TopClients.tsx index 947493044b1..a25fa50401d 100644 --- a/client_v2/src/components/Dashboard/blocks/TopClients/TopClients.tsx +++ b/client_v2/src/components/Dashboard/blocks/TopClients/TopClients.tsx @@ -1,4 +1,4 @@ -import { Show, For, createSignal, createMemo, onCleanup } from 'solid-js'; +import { Show, For, createSignal, createMemo } from 'solid-js'; import { useIsDesktop } from 'panel/helpers/useMediaQuery'; import intl from 'panel/common/intl'; @@ -6,22 +6,25 @@ import { Icon } from 'panel/common/ui/Icon'; import { Tooltip } from 'panel/common/ui/Tooltip'; import { QueriesTooltip } from 'panel/common/ui/QueriesTooltip'; import { Dropdown } from 'panel/common/ui/Dropdown'; -import { ConfirmDialog } from 'panel/common/ui/ConfirmDialog'; +import { + ClientBlockConfirmDialog, + useClientBlockConfirm, +} from 'panel/common/ui/ClientBlockConfirm'; import { Link } from 'panel/common/ui/Link'; import { RoutePath } from 'panel/components/Routes/Paths'; import { formatCompactNumber } from 'panel/helpers/helpers'; -import { addErrorToast } from 'panel/stores/toasts'; -import { accessState, toggleClientBlock } from 'panel/stores/access'; import theme from 'panel/lib/theme'; import cn from 'clsx'; import { useSortedData, TOP_CLIENTS_VISIBLE_ITEMS } from '../../hooks/useSortedData'; import { TableHeader } from '../TableHeader'; import { EmptyState } from '../EmptyState'; +import { CardFooter } from '../CardFooter'; import { ClientTooltip } from '../ClientTooltip'; import s from './TopClients.module.pcss'; import type { ClientFindSubEntry } from 'panel/api/model/clientFindSubEntry'; +import type { ClientBlockAction } from 'panel/common/ui/ClientBlockConfirm'; type ClientInfo = { name: string; @@ -32,24 +35,18 @@ type ClientInfo = { type Props = { topClients: ClientInfo[]; numDnsQueries: number; + period?: number; }; export const TopClients = (props: Props) => { - let isMounted = true; - onCleanup(() => { - isMounted = false; - }); + const { + confirmState: confirmDialog, + isClientBlocked, + openConfirmDialog, + closeConfirmDialog, + handleConfirm, + } = useClientBlockConfirm(); - const disallowedClientsList = createMemo(() => { - const str = accessState.disallowed_clients || ''; - return str ? str.split('\n').filter(Boolean) : []; - }); - - const [confirmDialog, setConfirmDialog] = createSignal<{ - open: boolean; - client: string; - action: 'block' | 'unblock'; - }>({ open: false, client: '', action: 'block' }); const [openMenuClient, setOpenMenuClient] = createSignal(null); const isDesktop = useIsDesktop(); @@ -58,42 +55,9 @@ export const TopClients = (props: Props) => { TOP_CLIENTS_VISIBLE_ITEMS, ); - const isClientBlocked = (clientName: string) => disallowedClientsList().includes(clientName); - - const handleBlockClient = async (clientIp: string) => { - const disallowedList = accessState.disallowed_clients - ? accessState.disallowed_clients.split('\n').filter(Boolean) - : []; - const isDisallowed = disallowedList.includes(clientIp); - if (isDisallowed) { - addErrorToast({ - error: new Error(intl.getMessage('client_already_blocked', { ip: clientIp })), - }); - if (isMounted) { - setConfirmDialog({ open: false, client: '', action: 'block' }); - } - return; - } - await toggleClientBlock(clientIp, false, ''); - if (isMounted) { - setConfirmDialog({ open: false, client: '', action: 'block' }); - } - }; - - const handleUnblockClient = async (clientIp: string) => { - const disallowedList = accessState.disallowed_clients - ? accessState.disallowed_clients.split('\n').filter(Boolean) - : []; - const isDisallowed = disallowedList.includes(clientIp); - await toggleClientBlock(clientIp, isDisallowed, isDisallowed ? clientIp : ''); - if (isMounted) { - setConfirmDialog({ open: false, client: '', action: 'unblock' }); - } - }; - - const openConfirmDialog = (client: string, action: 'block' | 'unblock') => { + const openClientConfirmDialog = (client: string, action: ClientBlockAction) => { setOpenMenuClient(null); - setConfirmDialog({ open: true, client, action }); + openConfirmDialog(client, action); }; const getClientMenu = (client: ClientInfo) => { @@ -109,7 +73,7 @@ export const TopClients = (props: Props) => { s.protectionMenuItem, s.protectionMenuItemRed, )} - onClick={() => openConfirmDialog(client.name, 'block')} + onClick={() => openClientConfirmDialog(client.name, 'block')} > {intl.getMessage('block_client')}
@@ -122,7 +86,7 @@ export const TopClients = (props: Props) => { theme.dropdown.item, s.protectionMenuItem, )} - onClick={() => openConfirmDialog(client.name, 'unblock')} + onClick={() => openClientConfirmDialog(client.name, 'unblock')} > {intl.getMessage('unblock_client')}
@@ -168,6 +132,7 @@ export const TopClients = (props: Props) => { s.clientIp, s.clientIpLink, )} + title={client.name} > { + } > { - {client.name} + {client.name}
@@ -290,6 +252,7 @@ export const TopClients = (props: Props) => { theme.text.condenced, s.clientName, )} + title={client.info.name} > {client.info.name}
@@ -341,51 +304,18 @@ export const TopClients = (props: Props) => { - - {(() => { - const dialog = confirmDialog(); - const isBlock = dialog.action === 'block'; - - return ( - - setConfirmDialog({ open: false, client: '', action: 'block' }) - } - title={ - isBlock - ? intl.getMessage('confirm_client_block_title', { - ip: dialog.client, - }) - : intl.getMessage('confirm_client_unblock_title', { - ip: dialog.client, - }) - } - text={ - isBlock - ? intl.getMessage('confirm_client_block_desc', { - ip: dialog.client, - }) - : intl.getMessage('confirm_client_unblock_desc', { - ip: dialog.client, - }) - } - buttonText={ - isBlock ? intl.getMessage('block') : intl.getMessage('unblock') - } - cancelText={intl.getMessage('cancel')} - buttonVariant={isBlock ? 'danger' : 'primary'} - onConfirm={() => { - if (isBlock) { - handleBlockClient(dialog.client); - } else { - handleUnblockClient(dialog.client); - } - }} - /> - ); - })()} - +
+ +
); }; diff --git a/client_v2/src/components/Dashboard/blocks/TopQueriedDomains/TopQueriedDomains.tsx b/client_v2/src/components/Dashboard/blocks/TopQueriedDomains/TopQueriedDomains.tsx index 5b0ee735027..7e65cc1cf90 100644 --- a/client_v2/src/components/Dashboard/blocks/TopQueriedDomains/TopQueriedDomains.tsx +++ b/client_v2/src/components/Dashboard/blocks/TopQueriedDomains/TopQueriedDomains.tsx @@ -12,6 +12,7 @@ import cn from 'clsx'; import { TableHeader } from '../TableHeader'; import { TrackerTooltip } from '../TrackerTooltip'; import { EmptyState } from '../EmptyState'; +import { CardFooter } from '../CardFooter'; import { useSortedData } from '../../hooks/useSortedData'; import s from '../TableCard.module.pcss'; @@ -24,6 +25,7 @@ type DomainInfo = { type Props = { topQueriedDomains: DomainInfo[]; numDnsQueries: number; + period?: number; }; export const TopQueriedDomains = (props: Props) => { @@ -83,7 +85,18 @@ export const TopQueriedDomains = (props: Props) => { - {domain.name} + + {domain.name} +
@@ -141,6 +154,12 @@ export const TopQueriedDomains = (props: Props) => {
+ + ); }; diff --git a/client_v2/src/components/Dashboard/blocks/TopUpstreams/TopUpstreams.tsx b/client_v2/src/components/Dashboard/blocks/TopUpstreams/TopUpstreams.tsx index 2f6aeb84b8c..4f9ab0b4a4b 100644 --- a/client_v2/src/components/Dashboard/blocks/TopUpstreams/TopUpstreams.tsx +++ b/client_v2/src/components/Dashboard/blocks/TopUpstreams/TopUpstreams.tsx @@ -3,9 +3,11 @@ import intl from 'panel/common/intl'; import { formatCompactNumber } from 'panel/helpers/helpers'; import theme from 'panel/lib/theme'; import { QueriesTooltip } from 'panel/common/ui/QueriesTooltip'; +import { RoutePath } from 'panel/components/Routes/Paths'; import cn from 'clsx'; import { TableHeader } from '../TableHeader'; import { EmptyState } from '../EmptyState'; +import { CardFooter } from '../CardFooter'; import { useSortedData } from '../../hooks/useSortedData'; import s from '../TableCard.module.pcss'; @@ -18,6 +20,7 @@ type UpstreamInfo = { type Props = { topUpstreamsResponses: UpstreamInfo[]; numDnsQueries: number; + period?: number; }; export const TopUpstreams = (props: Props) => { @@ -107,6 +110,12 @@ export const TopUpstreams = (props: Props) => { + + ); }; diff --git a/client_v2/src/components/Dashboard/blocks/UpstreamAvgTime/UpstreamAvgTime.tsx b/client_v2/src/components/Dashboard/blocks/UpstreamAvgTime/UpstreamAvgTime.tsx index d15e55c5148..7883b95c911 100644 --- a/client_v2/src/components/Dashboard/blocks/UpstreamAvgTime/UpstreamAvgTime.tsx +++ b/client_v2/src/components/Dashboard/blocks/UpstreamAvgTime/UpstreamAvgTime.tsx @@ -1,9 +1,11 @@ import { Show, For, createMemo } from 'solid-js'; import intl from 'panel/common/intl'; import theme from 'panel/lib/theme'; +import { RoutePath } from 'panel/components/Routes/Paths'; import cn from 'clsx'; import { TableHeader } from '../TableHeader'; import { EmptyState } from '../EmptyState'; +import { CardFooter } from '../CardFooter'; import { useSortedData } from '../../hooks/useSortedData'; import s from '../TableCard.module.pcss'; @@ -16,6 +18,7 @@ type UpstreamInfo = { type Props = { topUpstreamsAvgTime: UpstreamInfo[]; avgProcessingTime: number; + period?: number; }; export const UpstreamAvgTime = (props: Props) => { @@ -72,6 +75,12 @@ export const UpstreamAvgTime = (props: Props) => { + + ); }; diff --git a/client_v2/src/components/QueryLog/blocks/DetailModal/blocks/ActionFooter.tsx b/client_v2/src/components/QueryLog/blocks/DetailModal/blocks/ActionFooter.tsx index fe2e94575fe..64fc06ee359 100644 --- a/client_v2/src/components/QueryLog/blocks/DetailModal/blocks/ActionFooter.tsx +++ b/client_v2/src/components/QueryLog/blocks/DetailModal/blocks/ActionFooter.tsx @@ -107,7 +107,7 @@ export const ActionFooter = (props: Props) => { secondaryVariant?: ButtonVariant; testId: string; dataAction: string; - labelKey: string; + label: string; onClick: () => void; }; @@ -117,63 +117,63 @@ export const ActionFooter = (props: Props) => { secondaryVariant: 'secondary-danger', testId: 'query-log-detail-action-block', dataAction: 'block', - labelKey: 'block', + label: intl.getMessage('block'), onClick: handleBlock, }, 'add-to-allowlist': { variant: 'primary', testId: 'query-log-detail-action-allowlist', dataAction: 'allowlist', - labelKey: 'user_rules_add_to_allowlist', + label: intl.getMessage('user_rules_add_to_allowlist'), onClick: handleAddToAllowlist, }, 'allow-service': { variant: 'secondary', testId: 'query-log-detail-action-allow-service', dataAction: 'allow-service', - labelKey: 'user_rules_allow_service', + label: intl.getMessage('user_rules_allow_service'), onClick: handleAllowService, }, 'disable-filter': { variant: 'secondary', testId: 'query-log-detail-action-disable-filter', dataAction: 'disable-filter', - labelKey: 'user_rules_disable_filter', + label: intl.getMessage('user_rules_disable_filter'), onClick: handleDisableFilter, }, 'disable-browsing-security': { variant: 'secondary', testId: 'query-log-detail-action-disable-browsing-security', dataAction: 'disable-browsing-security', - labelKey: 'user_rules_disable_browsing_security', + label: intl.getMessage('user_rules_disable_browsing_security'), onClick: handleDisableSafeBrowsing, }, 'disable-parental': { variant: 'secondary', testId: 'query-log-detail-action-disable-parental', dataAction: 'disable-parental', - labelKey: 'user_rules_disable_parental_control', + label: intl.getMessage('user_rules_disable_parental_control'), onClick: handleDisableParental, }, 'disable-safe-search': { variant: 'secondary', testId: 'query-log-detail-action-disable-safe-search', dataAction: 'disable-safe-search', - labelKey: 'user_rules_disable_safe_search', + label: intl.getMessage('user_rules_disable_safe_search'), onClick: handleDisableSafeSearch, }, 'remove-dns-rewrite': { variant: 'primary', testId: 'query-log-detail-action-remove-dns-rewrite', dataAction: 'remove-dns-rewrite', - labelKey: 'user_rules_remove_dns_rewrite', + label: intl.getMessage('user_rules_remove_dns_rewrite'), onClick: handleRemoveRewrite, }, 'edit-dns-rewrite': { variant: 'secondary', testId: 'query-log-detail-action-edit-dns-rewrite', dataAction: 'edit-dns-rewrite', - labelKey: 'user_rules_edit_dns_rewrite', + label: intl.getMessage('user_rules_edit_dns_rewrite'), onClick: handleEditRewrite, }, }; @@ -199,7 +199,7 @@ export const ActionFooter = (props: Props) => { class={s.actionButton} onClick={config.onClick} > - {intl.getMessage(config.labelKey)} + {config.label} ); }} diff --git a/client_v2/src/components/QueryLog/blocks/Header/Header.tsx b/client_v2/src/components/QueryLog/blocks/Header/Header.tsx index e7e75655c39..d8e21ff3388 100644 --- a/client_v2/src/components/QueryLog/blocks/Header/Header.tsx +++ b/client_v2/src/components/QueryLog/blocks/Header/Header.tsx @@ -191,7 +191,7 @@ export const Header = (props: Props) => { - + } /> diff --git a/client_v2/src/components/Routes/Paths.ts b/client_v2/src/components/Routes/Paths.ts index 9822e9b845d..c62680f3fb0 100644 --- a/client_v2/src/components/Routes/Paths.ts +++ b/client_v2/src/components/Routes/Paths.ts @@ -29,6 +29,11 @@ export const RoutePath = { ClientsEditProtection: 'ClientsEditProtection', ClientsEditBlockedServices: 'ClientsEditBlockedServices', ClientsEditSchedule: 'ClientsEditSchedule', + TopClients: 'TopClients', + TopQueriedDomains: 'TopQueriedDomains', + TopBlockedDomains: 'TopBlockedDomains', + TopUpstreams: 'TopUpstreams', + UpstreamAvgTime: 'UpstreamAvgTime', } as const; export type RoutePathKey = keyof typeof RoutePath; @@ -64,6 +69,11 @@ export const Paths: Record = { ClientsEditProtection: pathBuilder('clients/edit/:clientName/protection'), ClientsEditBlockedServices: pathBuilder('clients/edit/:clientName/blocked_services'), ClientsEditSchedule: pathBuilder('clients/edit/:clientName/blocked_services/schedule'), + TopClients: pathBuilder('top_clients'), + TopQueriedDomains: pathBuilder('top_queried_domains'), + TopBlockedDomains: pathBuilder('top_blocked_domains'), + TopUpstreams: pathBuilder('top_upstreams'), + UpstreamAvgTime: pathBuilder('upstream_avg_time'), }; export type LinkParams = Partial>; diff --git a/client_v2/src/components/Stats/StatsPage/StatsPage.module.pcss b/client_v2/src/components/Stats/StatsPage/StatsPage.module.pcss new file mode 100644 index 00000000000..8fbdfbc6feb --- /dev/null +++ b/client_v2/src/components/Stats/StatsPage/StatsPage.module.pcss @@ -0,0 +1,210 @@ +.containerOverride { + padding-top: 0; +} + +.page { + display: flex; + flex-direction: column; + padding: 0 16px; + gap: 16px; + + @media (min-width: 1024px) { + gap: 32px; + } +} + +.header { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 16px; + + @media (min-width: 1024px) { + flex-direction: row; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + } +} + +.headerUnderTitle { + @media (min-width: 1024px) { + flex-direction: column; + align-items: stretch; + justify-content: flex-start; + flex-wrap: nowrap; + + & .headerControls { + flex-direction: row; + align-items: center; + flex-wrap: wrap; + gap: 16px; + } + + & .controlAfter { + order: 0; + margin-inline-end: auto; + } + + & .controlBefore { + order: 1; + } + + & .searchField { + order: 2; + } + + & .refreshButtonDesktop { + order: 3; + } + } +} + +.headerLeft { + display: flex; + flex-direction: column; + gap: 8px; + + @media (min-width: 1024px) { + gap: 48px; + } +} + +.titleRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.pageTitle { + margin: 0; +} + +.headerControls { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 16px; + + @media (min-width: 1024px) { + flex-direction: row; + align-items: center; + } +} + +.controlSlot { + min-width: 0; +} + +.controlAfter { + order: 0; +} + +.controlBefore { + order: 1; + + @media (min-width: 1024px) { + order: 0; + } +} + +.searchField { + flex: 0 1 auto; + width: 100%; + max-width: none; + min-width: 0; + order: 2; + overflow: visible; + + @media (min-width: 1024px) { + flex: 1 1 220px; + max-width: 320px; + min-width: 180px; + width: auto; + order: 0; + } +} + +.searchIcon { + width: 24px; + height: 24px; + color: var(--default-gray-icons); +} + +.refreshButton { + flex-shrink: 0; + width: 40px; + height: 40px; + min-width: 40px; + max-width: none; + padding: 0; +} + +.refreshButtonMobile { + display: block; + + @media (min-width: 1024px) { + display: none; + } +} + +.refreshButtonDesktop { + display: none; + + @media (min-width: 1024px) { + display: block; + } +} + +.refreshIcon { + width: 24px; + height: 24px; + color: var(--default-product-icon); +} + +.mobileContent { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mobileSort { + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 8px; +} + +.mobileSortLabel { + color: var(--default-description-text); +} + +.mobileCards { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mobilePagination { + padding-top: 8px; +} + +.mobileEmptyState { + min-height: 120px; + padding: 24px 16px; +} + +.mobileLoader { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} + +.mobileLoaderIcon { + width: 48px; + height: 48px; + color: var(--default-product-icon); +} diff --git a/client_v2/src/components/Stats/StatsPage/StatsPage.tsx b/client_v2/src/components/Stats/StatsPage/StatsPage.tsx new file mode 100644 index 00000000000..bb78d7b1e69 --- /dev/null +++ b/client_v2/src/components/Stats/StatsPage/StatsPage.tsx @@ -0,0 +1,335 @@ +import { For, Show, createEffect, createMemo, createSignal, untrack, type JSX } from 'solid-js'; +import cn from 'clsx'; +import { useSearchParams } from '@solidjs/router'; + +import intl from 'panel/common/intl'; +import theme from 'panel/lib/theme'; +import { Breadcrumbs } from 'panel/common/ui/Breadcrumbs'; +import { Table, Pagination, type TableColumn } from 'panel/common/ui/Table'; +import { DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE_OPTIONS } from 'panel/common/ui/Table/Table'; +import { Loader } from 'panel/common/ui/Loader'; +import { Input } from 'panel/common/controls/Input'; +import { Select } from 'panel/common/controls/Select'; +import { Button } from 'panel/common/ui/Button'; +import { Icon } from 'panel/common/ui/Icon'; +import { FaqTooltip } from 'panel/common/ui/FaqTooltip'; +import { RoutePath } from 'panel/components/Routes/Paths'; +import { useIsMobile } from 'panel/hooks/useIsMobile'; +import { LocalStorageHelper } from 'panel/helpers/localStorageHelper'; +import { isQueryMatch } from 'panel/helpers/statistics'; +import type { IOption } from 'panel/lib/helpers/utils'; +import { EmptyState } from '../blocks/EmptyState'; + +import s from './StatsPage.module.pcss'; + +type SortState = { + key: string; + direction: 'asc' | 'desc'; +}; + +type StatsPageProps = { + title: string; + rows: T[]; + columns: TableColumn[]; + getRowId: (row: T, index: number) => string | number; + defaultSort: SortState; + loading: boolean; + emptyText: string; + onRefresh: () => void; + searchTextForRow: (row: T) => string; + pageSizeKey: string; + sortStorageKey: string; + mobileSortOptions: IOption[]; + renderMobileCard: (row: T) => JSX.Element; + controlsBefore?: JSX.Element; + controlsAfter?: JSX.Element; + controlsUnderTitle?: boolean; + children?: JSX.Element; +}; + +export function StatsPage(props: StatsPageProps) { + const [searchQuery, setSearchQuery] = createSignal(''); + const [currentPage, setCurrentPage] = createSignal(0); + const isMobile = useIsMobile(); + const [searchParams, setSearchParams] = useSearchParams<{ sort?: string; dir?: string }>(); + + const filteredRows = createMemo(() => { + const query = searchQuery(); + if (!query.trim()) { + return props.rows; + } + return props.rows.filter((row) => isQueryMatch(props.searchTextForRow(row), query)); + }); + + const [pageSize, setPageSize] = createSignal( + untrack(() => LocalStorageHelper.getItem(props.pageSizeKey) ?? DEFAULT_PAGE_SIZE), + ); + + const getStoredSort = (): SortState | null => { + const stored = LocalStorageHelper.getItem(props.sortStorageKey); + if (!stored?.key || (stored.direction !== 'asc' && stored.direction !== 'desc')) { + return null; + } + return stored; + }; + + const resolvedSort = (): SortState => { + if (searchParams.sort && (searchParams.dir === 'asc' || searchParams.dir === 'desc')) { + return { key: searchParams.sort, direction: searchParams.dir }; + } + return getStoredSort() ?? props.defaultSort; + }; + + const handleSortChange = (key: string, direction: 'asc' | 'desc') => { + LocalStorageHelper.setItem(props.sortStorageKey, { key, direction }); + setSearchParams({ sort: key, dir: direction }, { replace: true }); + }; + + // Mobile sort select shares the desktop sort state (localStorage + URL). + const currentSortValue = createMemo(() => { + const sort = resolvedSort(); + return `${sort.key}:${sort.direction}`; + }); + + const handleMobileSortChange = (option: IOption) => { + const [key, direction] = option.value.split(':') as [string, 'asc' | 'desc']; + handleSortChange(key, direction); + setCurrentPage(0); + }; + + const mobileTotalPages = () => Math.max(1, Math.ceil(filteredRows().length / pageSize())); + + // The rows-per-page select is only meaningful once there is at least a full + // page of rows, so the whole footer is hidden for short lists — the same + // rule the desktop Table applies. + const showMobilePagination = () => filteredRows().length >= DEFAULT_PAGE_SIZE; + + // The desktop Table sorts internally; mirror that logic here so the mobile + // card list is ordered the same way (resolvedSort drives both). + const sortedMobileRows = createMemo(() => { + const data = filteredRows(); + const { key, direction } = resolvedSort(); + const column = props.columns.find((col) => col.key === key); + if (!column || column.sortable === false) { + return data; + } + + const accessor = + typeof column.accessor === 'function' + ? column.accessor + : column.accessor + ? (row: any) => (row as Record)[column.accessor as string] + : null; + if (!accessor) { + return data; + } + + return [...data].sort((a, b) => { + let aValue = accessor(a); + let bValue = accessor(b); + + if (aValue == null && bValue == null) return 0; + if (aValue == null) return 1; + if (bValue == null) return -1; + + if (typeof aValue === 'string' && typeof bValue === 'string') { + aValue = aValue.toLowerCase(); + bValue = bValue.toLowerCase(); + } + + let comparison: number; + if (column.sortFn) { + comparison = column.sortFn(aValue, bValue); + } else { + comparison = 0; + if (aValue < bValue) comparison = -1; + else if (aValue > bValue) comparison = 1; + } + + return direction === 'desc' ? -comparison : comparison; + }); + }); + + const mobilePagedRows = createMemo(() => { + const start = currentPage() * pageSize(); + return sortedMobileRows().slice(start, start + pageSize()); + }); + + const handlePageSizeChange = (size: number) => { + LocalStorageHelper.setItem(props.pageSizeKey, size); + setPageSize(size); + setCurrentPage(0); + }; + + createEffect(() => { + if (currentPage() >= mobileTotalPages()) { + setCurrentPage(0); + } + }); + + return ( +
+
+
+
+ +
+

+ {props.title} +

+ + + +
+
+ +
+ +
+ {props.controlsBefore} +
+
+ { + setSearchQuery((e.target as HTMLInputElement).value); + setCurrentPage(0); + }} + placeholder={intl.getMessage('search_placeholder')} + size={isMobile() ? 'large' : 'small'} + prefixIcon={} + suffixIcon={ + + } + isClearable + onClear={() => { + setSearchQuery(''); + setCurrentPage(0); + }} + /> + + + + +
+ {props.controlsAfter} +
+
+
+
+ + {props.children} + + + + +
+ } + > + 0} + fallback={ + + } + > +
+ + {intl.getMessage('sort_by')} + +
+ + options={props.mobileSortOptions} + value={props.mobileSortOptions.find( + (option) => option.value === currentSortValue(), + )} + onChange={handleMobileSortChange} + height="big" + isSearchable={false} + optionTestIdPrefix="stats-sort-option" + /> +
+
+
+ + {(row) => props.renderMobileCard(row)} + +
+ +
+ setCurrentPage(page)} + onPageSizeChange={handlePageSizeChange} + /> +
+
+
+ +
+ } + > + + data={filteredRows()} + columns={props.columns} + getRowId={props.getRowId} + defaultSort={resolvedSort()} + onSortChange={handleSortChange} + loading={props.loading} + pageSize={pageSize()} + onPageSizeChange={handlePageSizeChange} + emptyTable={} + /> + + + + ); +} diff --git a/client_v2/src/components/Stats/StatsPage/index.ts b/client_v2/src/components/Stats/StatsPage/index.ts new file mode 100644 index 00000000000..de3ad5be80b --- /dev/null +++ b/client_v2/src/components/Stats/StatsPage/index.ts @@ -0,0 +1 @@ +export { StatsPage } from './StatsPage'; diff --git a/client_v2/src/components/Stats/TopBlockedDomainsPage/TopBlockedDomainsPage.tsx b/client_v2/src/components/Stats/TopBlockedDomainsPage/TopBlockedDomainsPage.tsx new file mode 100644 index 00000000000..e4b3e6cc1a4 --- /dev/null +++ b/client_v2/src/components/Stats/TopBlockedDomainsPage/TopBlockedDomainsPage.tsx @@ -0,0 +1,101 @@ +import { createMemo, onMount } from 'solid-js'; + +import intl from 'panel/common/intl'; +import type { TableColumn } from 'panel/common/ui/Table'; +import { RoutePath } from 'panel/components/Routes/Paths'; +import { statsState } from 'panel/stores/stats'; +import { LOCAL_STORAGE_KEYS } from 'panel/helpers/localStorageHelper'; +import { computePercent } from 'panel/helpers/statistics'; +import type { IOption } from 'panel/lib/helpers/utils'; +import { StatsPage } from '../StatsPage'; +import { CountWithPercent } from '../blocks/CountWithPercent'; +import { NameCell } from '../blocks/NameCell'; +import { StatMobileCard } from '../blocks/StatMobileCard'; +import { useStatsRefresh } from '../hooks/useStatsRefresh'; + +type DomainStat = { name: string; count: number }; + +export const TopBlockedDomainsPage = () => { + const refreshStats = useStatsRefresh(); + + onMount(refreshStats); + + const rows = createMemo(() => + [...statsState.topBlockedDomains].toSorted((a, b) => a.name.localeCompare(b.name)), + ); + + const columns = (): TableColumn[] => [ + { + key: 'domain', + header: { text: intl.getMessage('domain') }, + accessor: 'name', + sortable: true, + render: (_v, row) => , + }, + { + key: 'queries', + header: { text: intl.getMessage('blocked_queries') }, + accessor: 'count', + sortable: true, + sortFn: (a: number, b: number) => a - b, + render: (_v, row) => ( + + ), + }, + ]; + + const mobileSortOptions = (): IOption[] => [ + { value: 'domain:asc', label: intl.getMessage('sort_domain_asc') }, + { value: 'domain:desc', label: intl.getMessage('sort_domain_desc') }, + { value: 'queries:desc', label: intl.getMessage('sort_queries_desc') }, + { value: 'queries:asc', label: intl.getMessage('sort_queries_asc') }, + ]; + + return ( + + title={intl.getMessage('top_blocked_domains')} + rows={rows()} + columns={columns()} + getRowId={(row) => row.name} + defaultSort={{ key: 'queries', direction: 'desc' }} + loading={statsState.processingStats} + emptyText={intl.getMessage('nothing_found')} + onRefresh={refreshStats} + searchTextForRow={(row) => row.name} + pageSizeKey={LOCAL_STORAGE_KEYS.TOP_BLOCKED_DOMAINS_PAGE_SIZE} + sortStorageKey={LOCAL_STORAGE_KEYS.TOP_BLOCKED_DOMAINS_SORT} + mobileSortOptions={mobileSortOptions()} + renderMobileCard={(row) => ( + + ), + progress: computePercent(row.count, statsState.numBlockedFiltering), + tone: 'danger', + }, + ]} + /> + )} + /> + ); +}; diff --git a/client_v2/src/components/Stats/TopBlockedDomainsPage/index.ts b/client_v2/src/components/Stats/TopBlockedDomainsPage/index.ts new file mode 100644 index 00000000000..4fce5af4762 --- /dev/null +++ b/client_v2/src/components/Stats/TopBlockedDomainsPage/index.ts @@ -0,0 +1 @@ +export { TopBlockedDomainsPage } from './TopBlockedDomainsPage'; diff --git a/client_v2/src/components/Stats/TopClientsPage/TopClientsPage.module.pcss b/client_v2/src/components/Stats/TopClientsPage/TopClientsPage.module.pcss new file mode 100644 index 00000000000..b24568c725f --- /dev/null +++ b/client_v2/src/components/Stats/TopClientsPage/TopClientsPage.module.pcss @@ -0,0 +1,202 @@ +.filterPill { + display: inline-flex; + align-items: center; + flex-shrink: 0; + gap: 16px; + height: 33px; + padding: 0 16px; + border: 1px solid var(--default-item-divider); + border-radius: 200px; + background-color: var(--default-secondary-button); + color: var(--default-secondary-button-stroke); + font-size: 14px; + line-height: 1.5; + white-space: nowrap; + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: var(--hovered-secondary-button); + } + + &:active { + background-color: var(--pressed-secondary-button); + } +} + +.filterPillActive { + border-color: var(--product-primary-50); +} + +.filterPillCross { + width: 20px; + height: 20px; +} + +.nameCell { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + word-break: break-word; +} + +.blockedStatus { + color: var(--default-error-link); +} + +.ipCellLink { + color: inherit; + text-decoration: none; + min-width: 0; + + &:hover, + &:focus { + color: inherit; + text-decoration: underline; + } +} + +.whoisCell { + display: flex; + flex-direction: column; + gap: 4px; + line-height: 1.4; +} + +.whoisSub { + display: flex; + align-items: center; + gap: 4px; + font-size: var(--fs-text-t4); + color: var(--default-description-text); + white-space: nowrap; +} + +.whoisDivider { + width: 1px; + height: 12px; + background-color: var(--default-item-divider); +} + +.actionsDropdown { + display: inline-flex; +} + +.actionsCell { + @media (min-width: 768px) { + padding: 8px 10px; + justify-content: center; + } +} + +.protectionMenu { + min-width: 200px; +} + +.protectionMenuItem { + display: flex; + align-items: center; + min-height: 56px; + padding: 16px; + cursor: pointer; + color: var(--default-link); + transition: background-color var(--t2); + + &:hover, + &:focus-visible { + background-color: var(--hovered-dropdown-menu-background); + outline: 0; + } + + &:active { + background-color: var(--pressed-dropdown-menu-background); + } +} + +.protectionMenuItemRed { + color: var(--default-error-link); +} + +.actionButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + cursor: pointer; +} + +.unblockLink { + color: var(--product-primary-50); +} + +.blockLink { + color: var(--default-error-link); +} + +.mobileTitle { + font-weight: var(--weight-semi-bold); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + word-break: break-word; +} + +.mobileBlocked { + color: var(--default-error-link); +} + +.mobileActionButton { + width: 100%; + height: 40px; + min-height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: transparent; + cursor: pointer; + font: inherit; + transition: + var(--t2) color, + var(--t2) border-color, + var(--t2) background-color; +} + +.mobileActionButtonGreen { + border: 1px solid var(--default-link); + color: var(--default-link); + + &:hover, + &:focus-visible { + color: var(--hovered-link); + border-color: var(--hovered-link); + background-color: var(--hovered-secondary-button); + } + + &:active { + color: var(--pressed-link); + border-color: var(--pressed-link); + } +} + +.mobileActionButtonRed { + border: 1px solid var(--default-danger-button); + color: var(--default-danger-button); + + &:hover, + &:focus-visible { + color: var(--hovered-danger-button); + border-color: var(--hovered-danger-button); + background-color: var(--hovered-secondary-danger-button); + } + + &:active { + color: var(--pressed-danger-button); + border-color: var(--pressed-danger-button); + } +} diff --git a/client_v2/src/components/Stats/TopClientsPage/TopClientsPage.tsx b/client_v2/src/components/Stats/TopClientsPage/TopClientsPage.tsx new file mode 100644 index 00000000000..0d982dad83e --- /dev/null +++ b/client_v2/src/components/Stats/TopClientsPage/TopClientsPage.tsx @@ -0,0 +1,380 @@ +import { Show, createMemo, createSignal, onMount } from 'solid-js'; +import cn from 'clsx'; +import { useNavigate } from '@solidjs/router'; + +import intl from 'panel/common/intl'; +import theme from 'panel/lib/theme'; +import type { TableColumn } from 'panel/common/ui/Table'; +import { Dropdown } from 'panel/common/ui/Dropdown'; +import { Link } from 'panel/common/ui/Link'; +import { + ClientBlockConfirmDialog, + type ClientBlockAction, + useClientBlockConfirm, +} from 'panel/common/ui/ClientBlockConfirm'; +import { Icon } from 'panel/common/ui/Icon'; +import { statsState } from 'panel/stores/stats'; +import { accessState, getAccessList } from 'panel/stores/access'; +import { initClientForm } from 'panel/stores/clientForm'; +import { LOCAL_STORAGE_KEYS } from 'panel/helpers/localStorageHelper'; +import { computePercent } from 'panel/helpers/statistics'; +import { splitByNewLine } from 'panel/helpers/helpers'; +import type { IOption } from 'panel/lib/helpers/utils'; +import { PlusButton } from 'panel/common/ui/PlusButton'; +import { Paths, RoutePath } from 'panel/components/Routes/Paths'; +import { StatsPage } from '../StatsPage'; +import { StatMobileCard, type StatCardItem } from '../blocks/StatMobileCard'; +import { CountWithPercent } from '../blocks/CountWithPercent'; +import { useStatsRefresh } from '../hooks/useStatsRefresh'; + +import s from './TopClientsPage.module.pcss'; + +type ClientStat = { + name: string; + count: number; + info?: { name?: string; whois_info?: Record }; +}; + +export const TopClientsPage = () => { + const navigate = useNavigate(); + const refreshStats = useStatsRefresh(); + + onMount(() => { + refreshStats(); + getAccessList(); + }); + + const handleAddClient = () => { + initClientForm(null); + navigate(Paths.ClientsAdd); + }; + + const handleRefresh = () => { + refreshStats(); + getAccessList(); + }; + + const { + confirmState: confirmDialog, + isClientBlocked: isBlocked, + openConfirmDialog, + closeConfirmDialog, + handleConfirm, + } = useClientBlockConfirm(); + + const [openMenuClient, setOpenMenuClient] = createSignal(null); + + const openClientConfirmDialog = (client: string, action: ClientBlockAction) => { + setOpenMenuClient(null); + openConfirmDialog(client, action); + }; + + const [showOnlyBlocked, setShowOnlyBlocked] = createSignal(false); + + // Track the access list: mappers run untracked, so rows must be + // re-created (cloned) to re-render block state. + const blockedSet = createMemo(() => new Set(splitByNewLine(accessState.disallowed_clients))); + + const rows = createMemo(() => { + void blockedSet(); + return [...statsState.topClients] + .toSorted((a, b) => (a.info?.name ?? a.name).localeCompare(b.info?.name ?? b.name)) + .map((row) => ({ ...row })); + }); + + const visibleRows = createMemo(() => + showOnlyBlocked() ? rows().filter((row) => blockedSet().has(row.name)) : rows(), + ); + + const whoisCountry = (row: ClientStat) => row.info?.whois_info?.country || ''; + const whoisCity = (row: ClientStat) => row.info?.whois_info?.city || ''; + const whoisOrg = (row: ClientStat) => + row.info?.whois_info?.orgname || row.info?.whois_info?.org || ''; + + const hasWhoisInfo = (row: ClientStat) => + Boolean(whoisOrg(row) || whoisCountry(row) || whoisCity(row)); + + const whoisCardItem = (row: ClientStat): StatCardItem => ({ + label: intl.getMessage('whois'), + layout: 'column', + value: ( + + +
{whoisOrg(row)}
+
+ +
+ {whoisCountry(row)} + + + + {whoisCity(row)} +
+
+
+ ), + }); + + const columns = (): TableColumn[] => [ + { + key: 'name', + header: { text: intl.getMessage('name_table_header') }, + accessor: (row) => row.info?.name || row.name, + sortable: true, + render: (_v, row) => ( + + {row.info?.name || '—'} + + ), + }, + { + key: 'status', + header: { text: intl.getMessage('status_table_header') }, + accessor: (row) => (isBlocked(row.name) ? 'blocked' : 'unblocked'), + sortable: false, + width: 93, + render: (_v, row) => ( + + + {intl.getMessage('blocked')} + + + ), + }, + { + key: 'queries', + header: { text: intl.getMessage('queries') }, + accessor: (row) => row.count, + sortable: true, + sortFn: (a: number, b: number) => a - b, + render: (_v, row) => ( + + ), + }, + { + key: 'ip', + header: { text: intl.getMessage('ip_address') }, + accessor: (row) => row.name, + sortable: true, + render: (_v, row) => ( + + {row.name} + + ), + }, + { + key: 'whois', + header: { text: intl.getMessage('whois') }, + accessor: (row) => `${whoisOrg(row)} ${whoisCountry(row)}`, + width: 219, + render: (_v, row) => ( + +
{whoisOrg(row) || '—'}
+ +
+ {whoisCountry(row)} + + + + {whoisCity(row)} +
+
+
+ ), + }, + { + key: 'actions', + header: { text: '' }, + width: 48, + class: s.actionsCell, + render: (_v, row) => ( + setOpenMenuClient(isOpen ? row.name : null)} + menu={ +
+ openClientConfirmDialog(row.name, 'block')} + > + {intl.getMessage('block_client')} +
+ } + > +
openClientConfirmDialog(row.name, 'unblock')} + > + {intl.getMessage('unblock_client')} +
+ + + } + > + +
+ ), + }, + ]; + + const mobileSortOptions = (): IOption[] => [ + { value: 'name:asc', label: intl.getMessage('sort_name_asc') }, + { value: 'name:desc', label: intl.getMessage('sort_name_desc') }, + { value: 'queries:desc', label: intl.getMessage('sort_queries_desc') }, + { value: 'queries:asc', label: intl.getMessage('sort_queries_asc') }, + { value: 'ip:desc', label: intl.getMessage('sort_ip_desc') }, + { value: 'ip:asc', label: intl.getMessage('sort_ip_asc') }, + ]; + + const mobileCard = (row: ClientStat) => { + const blocked = isBlocked(row.name); + return ( + + {row.info?.name || row.name} + + } + status={ + + + {intl.getMessage('blocked')} + + + } + items={[ + { + label: intl.getMessage('queries'), + value: ( + + ), + progress: computePercent(row.count, statsState.numDnsQueries), + }, + { + label: intl.getMessage('ip_address'), + value: ( + + {row.name} + + ), + }, + ...(hasWhoisInfo(row) ? [whoisCardItem(row)] : []), + ]} + actions={ + + } + /> + ); + }; + + const filterPill = ( + + ); + + return ( + <> + + title={intl.getMessage('top_clients')} + rows={visibleRows()} + columns={columns()} + getRowId={(row) => row.name} + defaultSort={{ key: 'queries', direction: 'desc' }} + loading={statsState.processingStats || accessState.processing} + emptyText={intl.getMessage('nothing_found')} + onRefresh={handleRefresh} + searchTextForRow={(row) => `${row.name} ${row.info?.name ?? ''}`} + pageSizeKey={LOCAL_STORAGE_KEYS.TOP_CLIENTS_PAGE_SIZE} + sortStorageKey={LOCAL_STORAGE_KEYS.TOP_CLIENTS_SORT} + mobileSortOptions={mobileSortOptions()} + renderMobileCard={(row) => mobileCard(row)} + controlsBefore={filterPill} + controlsUnderTitle + controlsAfter={ + handleAddClient()} testId="stats-add-client-button"> + {intl.getMessage('clients_add')} + + } + /> + + + + ); +}; diff --git a/client_v2/src/components/Stats/TopClientsPage/index.ts b/client_v2/src/components/Stats/TopClientsPage/index.ts new file mode 100644 index 00000000000..b1f5a7171bd --- /dev/null +++ b/client_v2/src/components/Stats/TopClientsPage/index.ts @@ -0,0 +1 @@ +export { TopClientsPage } from './TopClientsPage'; diff --git a/client_v2/src/components/Stats/TopQueriedDomainsPage/TopQueriedDomainsPage.tsx b/client_v2/src/components/Stats/TopQueriedDomainsPage/TopQueriedDomainsPage.tsx new file mode 100644 index 00000000000..7c6423d91e3 --- /dev/null +++ b/client_v2/src/components/Stats/TopQueriedDomainsPage/TopQueriedDomainsPage.tsx @@ -0,0 +1,98 @@ +import { createMemo, onMount } from 'solid-js'; + +import intl from 'panel/common/intl'; +import type { TableColumn } from 'panel/common/ui/Table'; +import { RoutePath } from 'panel/components/Routes/Paths'; +import { statsState } from 'panel/stores/stats'; +import { LOCAL_STORAGE_KEYS } from 'panel/helpers/localStorageHelper'; +import { computePercent } from 'panel/helpers/statistics'; +import type { IOption } from 'panel/lib/helpers/utils'; +import { StatsPage } from '../StatsPage'; +import { CountWithPercent } from '../blocks/CountWithPercent'; +import { NameCell } from '../blocks/NameCell'; +import { StatMobileCard } from '../blocks/StatMobileCard'; +import { useStatsRefresh } from '../hooks/useStatsRefresh'; + +type DomainStat = { name: string; count: number }; + +export const TopQueriedDomainsPage = () => { + const refreshStats = useStatsRefresh(); + + onMount(refreshStats); + + const rows = createMemo(() => + [...statsState.topQueriedDomains].toSorted((a, b) => a.name.localeCompare(b.name)), + ); + + const columns = (): TableColumn[] => [ + { + key: 'domain', + header: { text: intl.getMessage('domain') }, + accessor: 'name', + sortable: true, + render: (_v, row) => , + }, + { + key: 'queries', + header: { text: intl.getMessage('queries') }, + accessor: 'count', + sortable: true, + sortFn: (a: number, b: number) => a - b, + render: (_v, row) => ( + + ), + }, + ]; + + const mobileSortOptions = (): IOption[] => [ + { value: 'domain:asc', label: intl.getMessage('sort_domain_asc') }, + { value: 'domain:desc', label: intl.getMessage('sort_domain_desc') }, + { value: 'queries:desc', label: intl.getMessage('sort_queries_desc') }, + { value: 'queries:asc', label: intl.getMessage('sort_queries_asc') }, + ]; + + return ( + + title={intl.getMessage('stats_query_domain')} + rows={rows()} + columns={columns()} + getRowId={(row) => row.name} + defaultSort={{ key: 'queries', direction: 'desc' }} + loading={statsState.processingStats} + emptyText={intl.getMessage('nothing_found')} + onRefresh={refreshStats} + searchTextForRow={(row) => row.name} + pageSizeKey={LOCAL_STORAGE_KEYS.TOP_QUERIED_DOMAINS_PAGE_SIZE} + sortStorageKey={LOCAL_STORAGE_KEYS.TOP_QUERIED_DOMAINS_SORT} + mobileSortOptions={mobileSortOptions()} + renderMobileCard={(row) => ( + + ), + progress: computePercent(row.count, statsState.numDnsQueries), + }, + ]} + /> + )} + /> + ); +}; diff --git a/client_v2/src/components/Stats/TopQueriedDomainsPage/index.ts b/client_v2/src/components/Stats/TopQueriedDomainsPage/index.ts new file mode 100644 index 00000000000..3c644228feb --- /dev/null +++ b/client_v2/src/components/Stats/TopQueriedDomainsPage/index.ts @@ -0,0 +1 @@ +export { TopQueriedDomainsPage } from './TopQueriedDomainsPage'; diff --git a/client_v2/src/components/Stats/TopUpstreamsPage/TopUpstreamsPage.tsx b/client_v2/src/components/Stats/TopUpstreamsPage/TopUpstreamsPage.tsx new file mode 100644 index 00000000000..7c576b1a911 --- /dev/null +++ b/client_v2/src/components/Stats/TopUpstreamsPage/TopUpstreamsPage.tsx @@ -0,0 +1,86 @@ +import { createMemo, onMount } from 'solid-js'; + +import intl from 'panel/common/intl'; +import type { TableColumn } from 'panel/common/ui/Table'; +import { statsState } from 'panel/stores/stats'; +import { LOCAL_STORAGE_KEYS } from 'panel/helpers/localStorageHelper'; +import { computePercent } from 'panel/helpers/statistics'; +import type { IOption } from 'panel/lib/helpers/utils'; +import { StatsPage } from '../StatsPage'; +import { CountWithPercent } from '../blocks/CountWithPercent'; +import { NameCell } from '../blocks/NameCell'; +import { StatMobileCard } from '../blocks/StatMobileCard'; +import { useStatsRefresh } from '../hooks/useStatsRefresh'; + +type UpstreamStat = { name: string; count: number }; + +export const TopUpstreamsPage = () => { + const refreshStats = useStatsRefresh(); + + onMount(refreshStats); + + const rows = createMemo(() => + [...statsState.topUpstreamsResponses].toSorted((a, b) => a.name.localeCompare(b.name)), + ); + + const columns = (): TableColumn[] => [ + { + key: 'upstream', + header: { text: intl.getMessage('upstream') }, + accessor: 'name', + sortable: true, + render: (_v, row) => , + }, + { + key: 'queries', + header: { text: intl.getMessage('queries') }, + accessor: 'count', + sortable: true, + sortFn: (a: number, b: number) => a - b, + render: (_v, row) => ( + + ), + }, + ]; + + const mobileSortOptions = (): IOption[] => [ + { value: 'upstream:asc', label: intl.getMessage('sort_upstream_asc') }, + { value: 'upstream:desc', label: intl.getMessage('sort_upstream_desc') }, + { value: 'queries:desc', label: intl.getMessage('sort_queries_desc') }, + { value: 'queries:asc', label: intl.getMessage('sort_queries_asc') }, + ]; + + return ( + + title={intl.getMessage('top_upstreams')} + rows={rows()} + columns={columns()} + getRowId={(row) => row.name} + defaultSort={{ key: 'queries', direction: 'desc' }} + loading={statsState.processingStats} + emptyText={intl.getMessage('nothing_found')} + onRefresh={refreshStats} + searchTextForRow={(row) => row.name} + pageSizeKey={LOCAL_STORAGE_KEYS.TOP_UPSTREAMS_PAGE_SIZE} + sortStorageKey={LOCAL_STORAGE_KEYS.TOP_UPSTREAMS_SORT} + mobileSortOptions={mobileSortOptions()} + renderMobileCard={(row) => ( + + ), + progress: computePercent(row.count, statsState.numDnsQueries), + }, + ]} + /> + )} + /> + ); +}; diff --git a/client_v2/src/components/Stats/TopUpstreamsPage/index.ts b/client_v2/src/components/Stats/TopUpstreamsPage/index.ts new file mode 100644 index 00000000000..e980240bf44 --- /dev/null +++ b/client_v2/src/components/Stats/TopUpstreamsPage/index.ts @@ -0,0 +1 @@ +export { TopUpstreamsPage } from './TopUpstreamsPage'; diff --git a/client_v2/src/components/Stats/UpstreamAvgTimePage/UpstreamAvgTimePage.tsx b/client_v2/src/components/Stats/UpstreamAvgTimePage/UpstreamAvgTimePage.tsx new file mode 100644 index 00000000000..8dbab99854f --- /dev/null +++ b/client_v2/src/components/Stats/UpstreamAvgTimePage/UpstreamAvgTimePage.tsx @@ -0,0 +1,90 @@ +import { createMemo, onMount } from 'solid-js'; +import cn from 'clsx'; + +import intl from 'panel/common/intl'; +import theme from 'panel/lib/theme'; +import type { TableColumn } from 'panel/common/ui/Table'; +import { statsState } from 'panel/stores/stats'; +import { LOCAL_STORAGE_KEYS } from 'panel/helpers/localStorageHelper'; +import type { IOption } from 'panel/lib/helpers/utils'; +import { StatsPage } from '../StatsPage'; +import { NameCell } from '../blocks/NameCell'; +import { StatMobileCard } from '../blocks/StatMobileCard'; +import { useStatsRefresh } from '../hooks/useStatsRefresh'; + +type UpstreamStat = { name: string; count: number }; + +export const UpstreamAvgTimePage = () => { + const refreshStats = useStatsRefresh(); + + onMount(refreshStats); + + const rows = createMemo(() => + [...statsState.topUpstreamsAvgTime].toSorted((a, b) => a.name.localeCompare(b.name)), + ); + + const columns = (): TableColumn[] => [ + { + key: 'upstream', + header: { text: intl.getMessage('upstream') }, + accessor: 'name', + sortable: true, + render: (_v, row) => , + }, + { + key: 'time', + header: { text: intl.getMessage('avg_response_time') }, + accessor: 'count', + sortable: true, + sortFn: (a: number, b: number) => a - b, + render: (_v, row) => ( + + {(row.count ?? 0).toFixed(0)} {intl.getMessage('milliseconds_abbreviation')} + + ), + }, + ]; + + const mobileSortOptions = (): IOption[] => [ + { value: 'upstream:asc', label: intl.getMessage('sort_upstream_asc') }, + { value: 'upstream:desc', label: intl.getMessage('sort_upstream_desc') }, + { value: 'time:desc', label: intl.getMessage('sort_response_time_desc') }, + { value: 'time:asc', label: intl.getMessage('sort_response_time_asc') }, + ]; + + return ( + + title={intl.getMessage('average_upstream_response_time')} + rows={rows()} + columns={columns()} + getRowId={(row) => row.name} + defaultSort={{ key: 'time', direction: 'desc' }} + loading={statsState.processingStats} + emptyText={intl.getMessage('nothing_found')} + onRefresh={refreshStats} + searchTextForRow={(row) => row.name} + pageSizeKey={LOCAL_STORAGE_KEYS.UPSTREAM_AVG_TIME_PAGE_SIZE} + sortStorageKey={LOCAL_STORAGE_KEYS.UPSTREAM_AVG_TIME_SORT} + mobileSortOptions={mobileSortOptions()} + renderMobileCard={(row) => ( + + {(row.count ?? 0).toFixed(0)}{' '} + {intl.getMessage('milliseconds_abbreviation')} + + ), + }, + ]} + /> + )} + /> + ); +}; diff --git a/client_v2/src/components/Stats/UpstreamAvgTimePage/index.ts b/client_v2/src/components/Stats/UpstreamAvgTimePage/index.ts new file mode 100644 index 00000000000..8989b84dc53 --- /dev/null +++ b/client_v2/src/components/Stats/UpstreamAvgTimePage/index.ts @@ -0,0 +1 @@ +export { UpstreamAvgTimePage } from './UpstreamAvgTimePage'; diff --git a/client_v2/src/components/Stats/blocks/CountWithPercent/CountWithPercent.module.pcss b/client_v2/src/components/Stats/blocks/CountWithPercent/CountWithPercent.module.pcss new file mode 100644 index 00000000000..f4a3790ec5e --- /dev/null +++ b/client_v2/src/components/Stats/blocks/CountWithPercent/CountWithPercent.module.pcss @@ -0,0 +1,51 @@ +.countCell { + display: inline-flex; + align-items: baseline; + gap: 4px; +} + +.countCellWithProgress { + align-items: center; + gap: 4px; +} + +.progressBar { + display: block; + flex-shrink: 0; + width: 102px; + height: 3px; + overflow: hidden; + border-radius: 2px; + background: var(--default-item-divider); + margin-right: 12px; +} + +.progressBarFill { + display: block; + height: 100%; + border-radius: 2px; + background: var(--default-tertiary-stat); +} + +.progressBarFillDanger { + background: var(--default-error-link); +} + +.percent { + color: var(--default-description-text); +} + +.danger { + color: var(--default-error-link); +} + +.countLink { + color: inherit; + text-decoration: none; + + &:hover, + &:focus { + color: inherit; + text-decoration: underline; + } +} diff --git a/client_v2/src/components/Stats/blocks/CountWithPercent/CountWithPercent.tsx b/client_v2/src/components/Stats/blocks/CountWithPercent/CountWithPercent.tsx new file mode 100644 index 00000000000..7da56dc1700 --- /dev/null +++ b/client_v2/src/components/Stats/blocks/CountWithPercent/CountWithPercent.tsx @@ -0,0 +1,68 @@ +import { Show, createMemo } from 'solid-js'; +import cn from 'clsx'; + +import theme from 'panel/lib/theme'; +import { formatCompactNumber } from 'panel/helpers/helpers'; +import { computePercent } from 'panel/helpers/statistics'; +import { Link } from 'panel/common/ui/Link'; +import { RoutePath } from 'panel/components/Routes/Paths'; + +import s from './CountWithPercent.module.pcss'; + +type Props = { + count: number; + total: number; + tone?: 'default' | 'danger'; + /** When set, the count becomes a QueryLog link filtered by this value. */ + queryLogSearch?: string; + /** When set, renders a progress bar filled to this percent (0-100) before the count. */ + progress?: number; +}; + +export const CountWithPercent = (props: Props) => { + const search = createMemo(() => props.queryLogSearch); + + // Integer percent for large shares (>= 10% or 0), one decimal for small ones. + const percentText = createMemo(() => { + const percent = computePercent(props.count, props.total); + return percent >= 10 || percent === 0 ? String(Math.round(percent)) : percent.toFixed(1); + }); + + return ( + + + + + + + + + {formatCompactNumber(props.count)} + + + + ({percentText()}%) + + + ); +}; diff --git a/client_v2/src/components/Stats/blocks/CountWithPercent/index.ts b/client_v2/src/components/Stats/blocks/CountWithPercent/index.ts new file mode 100644 index 00000000000..41e0fc6f1f4 --- /dev/null +++ b/client_v2/src/components/Stats/blocks/CountWithPercent/index.ts @@ -0,0 +1 @@ +export { CountWithPercent } from './CountWithPercent'; diff --git a/client_v2/src/components/Stats/blocks/EmptyState/EmptyState.module.pcss b/client_v2/src/components/Stats/blocks/EmptyState/EmptyState.module.pcss new file mode 100644 index 00000000000..8beb7fe7d84 --- /dev/null +++ b/client_v2/src/components/Stats/blocks/EmptyState/EmptyState.module.pcss @@ -0,0 +1,21 @@ +.root { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + text-align: center; +} + +.iconWrap { + display: inline-flex; +} + +.icon { + width: 64px; + height: 64px; +} + +.message { + color: var(--default-main-text); + overflow-wrap: anywhere; +} diff --git a/client_v2/src/components/Stats/blocks/EmptyState/EmptyState.tsx b/client_v2/src/components/Stats/blocks/EmptyState/EmptyState.tsx new file mode 100644 index 00000000000..6c08094d63a --- /dev/null +++ b/client_v2/src/components/Stats/blocks/EmptyState/EmptyState.tsx @@ -0,0 +1,22 @@ +import cn from 'clsx'; + +import { Icon } from 'panel/common/ui/Icon'; +import theme from 'panel/lib/theme'; + +import s from './EmptyState.module.pcss'; + +type Props = { + message: string; + class?: string; + messageClass?: string; +}; + +export const EmptyState = (props: Props) => ( +
+
+ +
+ +
{props.message}
+
+); diff --git a/client_v2/src/components/Stats/blocks/EmptyState/index.ts b/client_v2/src/components/Stats/blocks/EmptyState/index.ts new file mode 100644 index 00000000000..89798dbd685 --- /dev/null +++ b/client_v2/src/components/Stats/blocks/EmptyState/index.ts @@ -0,0 +1 @@ +export { EmptyState } from './EmptyState'; diff --git a/client_v2/src/components/Stats/blocks/NameCell/NameCell.module.pcss b/client_v2/src/components/Stats/blocks/NameCell/NameCell.module.pcss new file mode 100644 index 00000000000..b4f43479965 --- /dev/null +++ b/client_v2/src/components/Stats/blocks/NameCell/NameCell.module.pcss @@ -0,0 +1,15 @@ +.nameCell { + max-width: 420px; +} + +.nameCellLink { + color: inherit; + text-decoration: none; + min-width: 0; + + &:hover, + &:focus { + color: inherit; + text-decoration: underline; + } +} diff --git a/client_v2/src/components/Stats/blocks/NameCell/NameCell.tsx b/client_v2/src/components/Stats/blocks/NameCell/NameCell.tsx new file mode 100644 index 00000000000..53cc48d984f --- /dev/null +++ b/client_v2/src/components/Stats/blocks/NameCell/NameCell.tsx @@ -0,0 +1,41 @@ +import { Show } from 'solid-js'; +import cn from 'clsx'; + +import theme from 'panel/lib/theme'; +import { Link } from 'panel/common/ui/Link'; +import { RoutePath } from 'panel/components/Routes/Paths'; + +import s from './NameCell.module.pcss'; + +type Props = { + name: string; + testId?: string; + /** When set, the name becomes a QueryLog link filtered by this value. */ + queryLogSearch?: string; +}; + +/** Name cell with the shared truncation styles used by the top stats tables. */ +export const NameCell = (props: Props) => { + const content = ( + + {props.name} + + ); + + return ( + + + {content} + + + ); +}; diff --git a/client_v2/src/components/Stats/blocks/NameCell/index.ts b/client_v2/src/components/Stats/blocks/NameCell/index.ts new file mode 100644 index 00000000000..4aa75a03dfa --- /dev/null +++ b/client_v2/src/components/Stats/blocks/NameCell/index.ts @@ -0,0 +1 @@ +export { NameCell } from './NameCell'; diff --git a/client_v2/src/components/Stats/blocks/StatMobileCard/StatMobileCard.module.pcss b/client_v2/src/components/Stats/blocks/StatMobileCard/StatMobileCard.module.pcss new file mode 100644 index 00000000000..6e8ea57e3a8 --- /dev/null +++ b/client_v2/src/components/Stats/blocks/StatMobileCard/StatMobileCard.module.pcss @@ -0,0 +1,79 @@ +.card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 24px 16px; + border-radius: 8px; + background-color: var(--default-cards-background); + box-shadow: 8px 8px 24px 0 rgba(0, 0, 0, 0.1); +} + +.title { + font-weight: var(--weight-semi-bold); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + word-break: break-word; +} + +.titleLink { + color: inherit; + text-decoration: none; + + &:hover, + &:focus { + color: inherit; + text-decoration: underline; + } +} + +.row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + min-width: 0; +} + +.rowColumn { + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.rowLabel { + flex-shrink: 0; + color: var(--default-description-text); +} + +.rowValue { + min-width: 0; + overflow-wrap: anywhere; + text-align: right; +} + +.rowValueColumn { + text-align: left; +} + +.progress { + height: 3px; + border-radius: 2px; + background: var(--default-item-divider); + overflow: hidden; + margin-top: -8px; +} + +.progressFill { + height: 100%; + background: var(--default-tertiary-stat); +} + +.progressFillDanger { + background: var(--default-error-link); +} + +.actions { + padding-top: 4px; +} diff --git a/client_v2/src/components/Stats/blocks/StatMobileCard/StatMobileCard.tsx b/client_v2/src/components/Stats/blocks/StatMobileCard/StatMobileCard.tsx new file mode 100644 index 00000000000..f3d4abeac04 --- /dev/null +++ b/client_v2/src/components/Stats/blocks/StatMobileCard/StatMobileCard.tsx @@ -0,0 +1,86 @@ +import { For, Show, type JSX } from 'solid-js'; +import cn from 'clsx'; + +import theme from 'panel/lib/theme'; +import { Link } from 'panel/common/ui/Link'; +import type { QueryParams, RoutePathKey } from 'panel/components/Routes/Paths'; + +import s from './StatMobileCard.module.pcss'; + +type TitleLink = { + to: RoutePathKey; + query?: QueryParams; + title?: string; +}; + +export type StatCardItem = { + label: string; + value: JSX.Element; + /** 0..100 share; renders a progress bar under this row. */ + progress?: number; + /** Color variant for the row's progress bar fill. */ + tone?: 'default' | 'danger'; + /** 'row' (default): label left / value right. 'column': label above value. */ + layout?: 'row' | 'column'; +}; + +type Props = { + /** Card title (client name), displayed bold at the top. */ + title?: JSX.Element; + /** When set, the title is rendered as a link (keeps its color, underlines on hover). */ + titleLink?: TitleLink; + /** Status line rendered right under the title, e.g. "Blocked". */ + status?: JSX.Element; + items: StatCardItem[]; + actions?: JSX.Element; +}; + +export const StatMobileCard = (props: Props) => ( +
+ + + + {(link) => ( + + {props.title} + + )} + + + + +
{props.status}
+
+ + {(item) => ( + <> +
+ {item.label} + + {item.value} + +
+ +
+
+
+ + + )} + + +
{props.actions}
+
+
+); diff --git a/client_v2/src/components/Stats/blocks/StatMobileCard/index.ts b/client_v2/src/components/Stats/blocks/StatMobileCard/index.ts new file mode 100644 index 00000000000..1b93a0e5c40 --- /dev/null +++ b/client_v2/src/components/Stats/blocks/StatMobileCard/index.ts @@ -0,0 +1 @@ +export { StatMobileCard, type StatCardItem } from './StatMobileCard'; diff --git a/client_v2/src/components/Stats/hooks/useStatsRefresh.ts b/client_v2/src/components/Stats/hooks/useStatsRefresh.ts new file mode 100644 index 00000000000..1d055929714 --- /dev/null +++ b/client_v2/src/components/Stats/hooks/useStatsRefresh.ts @@ -0,0 +1,23 @@ +import { useSearchParams } from '@solidjs/router'; + +import { statsState, getStats, getStatsConfig } from 'panel/stores/stats'; +import { resolveStatsPeriod } from 'panel/helpers/statistics'; + +/** + * Refresh callback shared by all stats detail pages: fetches stats for the + * period from the URL (`?period=`) or the last stored period. + * + * The stats config is loaded first (if not yet fetched) so that the URL period + * is clamped by the real server retention and not by the default `DAY` value + * that the store starts with before `getStatsConfig` resolves. + */ +export const useStatsRefresh = () => { + const [searchParams] = useSearchParams<{ period?: string }>(); + + return async () => { + if (!statsState.configLoaded) { + await getStatsConfig(); + } + getStats(resolveStatsPeriod(searchParams, statsState.interval)); + }; +}; diff --git a/client_v2/src/components/Stats/index.ts b/client_v2/src/components/Stats/index.ts new file mode 100644 index 00000000000..c0c2196693b --- /dev/null +++ b/client_v2/src/components/Stats/index.ts @@ -0,0 +1,7 @@ +export { StatsPage } from './StatsPage'; +export { StatMobileCard, type StatCardItem } from './blocks/StatMobileCard/StatMobileCard'; +export { TopClientsPage } from './TopClientsPage'; +export { TopQueriedDomainsPage } from './TopQueriedDomainsPage'; +export { TopBlockedDomainsPage } from './TopBlockedDomainsPage'; +export { TopUpstreamsPage } from './TopUpstreamsPage'; +export { UpstreamAvgTimePage } from './UpstreamAvgTimePage'; diff --git a/client_v2/src/helpers/localStorageHelper.ts b/client_v2/src/helpers/localStorageHelper.ts index 5132e8506ee..a24de97e3eb 100644 --- a/client_v2/src/helpers/localStorageHelper.ts +++ b/client_v2/src/helpers/localStorageHelper.ts @@ -7,6 +7,16 @@ export const LOCAL_STORAGE_KEYS = { AUTO_CLIENTS_PAGE_SIZE: 'auto_clients_page_size', LANGUAGE: 'language', STATS_PERIOD: 'stats_period', + TOP_CLIENTS_PAGE_SIZE: 'top_clients_page_size', + TOP_QUERIED_DOMAINS_PAGE_SIZE: 'top_queried_domains_page_size', + TOP_BLOCKED_DOMAINS_PAGE_SIZE: 'top_blocked_domains_page_size', + TOP_UPSTREAMS_PAGE_SIZE: 'top_upstreams_page_size', + UPSTREAM_AVG_TIME_PAGE_SIZE: 'upstream_avg_time_page_size', + TOP_CLIENTS_SORT: 'top_clients_sort', + TOP_QUERIED_DOMAINS_SORT: 'top_queried_domains_sort', + TOP_BLOCKED_DOMAINS_SORT: 'top_blocked_domains_sort', + TOP_UPSTREAMS_SORT: 'top_upstreams_sort', + UPSTREAM_AVG_TIME_SORT: 'upstream_avg_time_sort', }; export const LocalStorageHelper = { diff --git a/client_v2/src/helpers/statistics.ts b/client_v2/src/helpers/statistics.ts new file mode 100644 index 00000000000..2036983221a --- /dev/null +++ b/client_v2/src/helpers/statistics.ts @@ -0,0 +1,59 @@ +import { DAY, HOUR } from 'panel/helpers/constants'; +import { LocalStorageHelper, LOCAL_STORAGE_KEYS } from 'panel/helpers/localStorageHelper'; + +/** + * Client-side search match: substring, case-insensitive. + * A double-quoted query ("...") matches a whitespace-separated token of the + * value exactly (so an IP matches a "IP display-name" search text). + * Blank/whitespace/quotes-only queries match everything. + */ +export const isQueryMatch = (value: string, query: string): boolean => { + const trimmed = query.trim(); + if (!trimmed) { + return true; + } + + const quoted = trimmed.match(/^"([\s\S]*)"$/); + if (quoted) { + return !quoted[1] || value.split(/\s+/).includes(quoted[1]); + } + + return value.toLowerCase().includes(trimmed.toLowerCase()); +}; + +/** Stats period saved by the dashboard; defaults to the default stats interval (24h = DAY). */ +export const getStoredStatsPeriod = (): number => { + const savedPeriod = LocalStorageHelper.getItem(LOCAL_STORAGE_KEYS.STATS_PERIOD); + return typeof savedPeriod === 'number' && Number.isFinite(savedPeriod) && savedPeriod > 0 + ? savedPeriod + : DAY; +}; + +/** Maximum stats interval from the server config; falls back to DAY if the value is unexpectedly small. */ +const getClampedMaxInterval = (maxInterval: number): number => + maxInterval >= HOUR ? maxInterval : DAY; +/** Saved period clamped by the maximum interval available in the stats config. */ +export const getEffectiveStatsPeriod = (maxInterval: number): number => + Math.min(getStoredStatsPeriod(), getClampedMaxInterval(maxInterval)); + +/** Stats period from URL search params; null when absent or invalid. */ +export const getStatsPeriodFromUrl = (params: { period?: string }): number | null => { + if (params.period == null) { + return null; + } + const period = Number(params.period); + return Number.isFinite(period) && period > 0 ? period : null; +}; + +/** Stats period from URL (if valid, clamped by the max interval), otherwise stored/period. */ +export const resolveStatsPeriod = (params: { period?: string }, maxInterval: number): number => { + const urlPeriod = getStatsPeriodFromUrl(params); + if (urlPeriod !== null) { + return Math.min(urlPeriod, getClampedMaxInterval(maxInterval)); + } + return getEffectiveStatsPeriod(maxInterval); +}; + +/** Percentage of a part in a total; 0 when the total is 0 (never NaN). */ +export const computePercent = (count: number, total: number): number => + total > 0 ? (count / total) * 100 : 0; diff --git a/client_v2/src/lib/theme/Pagination.module.pcss b/client_v2/src/lib/theme/Pagination.module.pcss index d7a0019fc21..40dd2089117 100644 --- a/client_v2/src/lib/theme/Pagination.module.pcss +++ b/client_v2/src/lib/theme/Pagination.module.pcss @@ -10,23 +10,6 @@ justify-content: space-between; padding: 16px 0; } - - & .dropdownShowOnPage { - width: 100%; - height: 48px; - display: inline-flex; - align-items: center; - justify-content: space-between; - padding: 4px 12px; - border: 1px solid var(--default-item-divider); - border-radius: 8px; - - @media (min-width: 768px) { - width: fit-content; - height: 32px; - margin-left: auto; - } - } } .pagesContainer { @@ -42,6 +25,14 @@ width: auto; margin-left: auto; } + + & .select { + width: 100%; + + @media (min-width: 768px) { + width: fit-content; + } + } } .arrow { @@ -111,16 +102,6 @@ } } -.dropdown { - width: 100%; - justify-content: space-between; - - @media (--s-viewport) { - width: auto; - justify-content: flex-start; - } -} - .summary { margin: 0 8px; line-height: 24px; diff --git a/client_v2/src/lib/theme/Table.module.pcss b/client_v2/src/lib/theme/Table.module.pcss index c64d6f07e32..d956ff9e5c6 100644 --- a/client_v2/src/lib/theme/Table.module.pcss +++ b/client_v2/src/lib/theme/Table.module.pcss @@ -115,8 +115,7 @@ } .action { - width: 100%; - display: flex; + display: inline-flex; align-items: center; gap: 8px; border: 0; @@ -134,20 +133,16 @@ } @media (min-width: 768px) { + display: flex; width: 24px; height: 24px; min-height: unset; justify-content: center; gap: 0; - } - - &:hover, - &:focus { - background-color: var(--hovered-page-background); - } - &:active { - background-color: var(--pressed-page-background); + &:active { + background-color: var(--pressed-page-background); + } } } diff --git a/client_v2/src/stores/stats.ts b/client_v2/src/stores/stats.ts index 54128df0d25..f8d3ecfa4bd 100644 --- a/client_v2/src/stores/stats.ts +++ b/client_v2/src/stores/stats.ts @@ -25,6 +25,8 @@ type StatsState = { processingSetConfig: boolean; processingStats: boolean; processingReset: boolean; + /** Whether the stats config (interval etc.) has been fetched from the server. */ + configLoaded: boolean; interval: number; customInterval: number | null; dnsQueries: number[]; @@ -57,6 +59,7 @@ const initialState: StatsState = { processingSetConfig: false, processingStats: true, processingReset: false, + configLoaded: false, interval: DAY, customInterval: null, dnsQueries: [], @@ -137,6 +140,7 @@ export const getStatsConfig = async () => { ignored: data.ignored || [], ignored_enabled: data.ignored_enabled ?? false, processingGetConfig: false, + configLoaded: true, }); } catch (error) { addErrorToast({ error });