From 522c5c53338118c57f99278624d5d0c2f665b368 Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 13 Aug 2026 15:17:48 +0200 Subject: [PATCH 01/10] fix(app): center the new-queries pill and drop the refresh-button hover accent Signed-off-by: Maciek --- app/src/pages/logs/Filters.tsx | 10 +++------- app/src/pages/logs/Logs.tsx | 36 +++++++++++++++++++--------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/app/src/pages/logs/Filters.tsx b/app/src/pages/logs/Filters.tsx index 621e631f..34970c78 100644 --- a/app/src/pages/logs/Filters.tsx +++ b/app/src/pages/logs/Filters.tsx @@ -60,15 +60,11 @@ const RefreshControls = ({ const activeOption = QUERY_LOGS_REFRESH_INTERVALS.find(option => option.key === refreshIntervalKey); const isAutoRefreshing = (activeOption?.ms ?? null) !== null; return ( - // `group` scopes the hover cue: pointing at either half tints the whole split - // button's border, so it reads as one control. Border-only — the `!bg` override - // (needed to sit flush on the page background) suppresses the outline variant's - // hover background, and a louder cue would compete with the filter accents. -
+
+ )} +
+
{!logsEnabled && ( @@ -589,22 +609,6 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => {
)}
- {/* Wrapper stays mounted so the live region exists before the pill text arrives. */} -
- {pendingLogs.length > 0 && ( - - )} -
{!expandHintDismissed && logs.length > 0 && (
Date: Thu, 13 Aug 2026 19:34:49 +0200 Subject: [PATCH 02/10] feat(app): pin the logs filter bar below the app header while scrolling Signed-off-by: Maciek --- app/src/App.tsx | 5 +- .../e2e/logs/logs-sticky-filters.spec.ts | 66 +++++++++++++++++++ app/src/pages/logs/Logs.tsx | 55 ++++++++++------ 3 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 app/src/__tests__/e2e/logs/logs-sticky-filters.spec.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index 1228b3fc..b8b88114 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -585,7 +585,10 @@ function ProtectedLayout() { // (same bug class as the old header wrapper transition, #121). // Mobile top offset comes from the sticky in-flow header; flex-1 // (BaseLayout is flex-col) replaces the old measured minHeight. - className={`bg-[var(--shadcn-ui-app-background)] w-full overflow-x-hidden box-border ${isDesktop ? 'transition-all duration-200' : 'flex-1'}`} + // overflow-x-clip, NOT -hidden: `hidden` computes overflow-y:auto and turns + // this into a scroll container, which silently breaks position:sticky in + // every page below (e.g. the logs sticky filter bar). + className={`bg-[var(--shadcn-ui-app-background)] w-full overflow-x-clip box-border ${isDesktop ? 'transition-all duration-200' : 'flex-1'}`} style={isDesktop ? { paddingTop: 'var(--app-header-stack, 64px)', marginLeft: `${sidebarWidth + shellOffset}px`, diff --git a/app/src/__tests__/e2e/logs/logs-sticky-filters.spec.ts b/app/src/__tests__/e2e/logs/logs-sticky-filters.spec.ts new file mode 100644 index 00000000..a6c538f1 --- /dev/null +++ b/app/src/__tests__/e2e/logs/logs-sticky-filters.spec.ts @@ -0,0 +1,66 @@ +import { test, expect, type Page } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// Sticky filter bar (issue item 5): the Filters row pins below the app header while +// the list scrolls, opaque, without introducing horizontal overflow (the enabling +// App.tsx change swaps app-content's overflow-x-hidden for overflow-x-clip — `hidden` +// creates a scroll container that silently breaks position:sticky). + +const profile = { id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }; + +const manyRows = Array.from({ length: 60 }).map((_, i) => ({ + profile_id: 'prof1', + timestamp: `2026-08-13T10:${Math.floor(i / 60).toString().padStart(2, '0')}:${(59 - (i % 60)).toString().padStart(2, '0')}Z`, + status: 'processed', + protocol: 'dns', + device_id: `device-${i}`, + client_ip: `10.0.0.${i}`, + dns_request: { domain: `row-${i}.example.test`, query_type: 'A' }, +})); + +// Register the logs route AFTER registerMocks so it is tested BEFORE the catch-all +// route (Playwright matches routes in reverse registration order). +async function setupLogsPage(page: Page) { + await registerMocks(page, { authenticated: true, customProfiles: [profile] }); + await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(manyRows) }); + }); + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').waitFor({ state: 'attached', timeout: 10000 }); +} + +test.describe('Logs sticky filter bar', () => { + test('filters pin below the header while the list scrolls, opaque, no horizontal overflow', async ({ page }) => { + await setupLogsPage(page); + const sticky = page.getByTestId('logs-sticky-filters'); + + expect(await sticky.evaluate(el => getComputedStyle(el).position)).toBe('sticky'); + + const before = (await sticky.boundingBox())!; + // scrollTo instead of mouse.wheel — the wheel API is unsupported on the + // mobile-WebKit project. + await page.evaluate(() => window.scrollTo(0, 1500)); + await page.waitForFunction(() => window.scrollY > 800); + + const after = (await sticky.boundingBox())!; + // The bar must NOT have scrolled away with the content... + expect(after.y).toBeGreaterThan(-1); + // ...and must sit exactly at its sticky offset: the full header-stack height. + const expectedTop = await page.evaluate(() => + parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--app-header-stack-full')) || 64 + ); + expect(Math.abs(after.y - expectedTop)).toBeLessThanOrEqual(2); + // Sanity: the page actually scrolled under it. + expect(before.y).toBeGreaterThanOrEqual(after.y); + + // Opaque surface — scrolled content cannot show through. + const bg = await sticky.evaluate(el => getComputedStyle(el).backgroundColor); + expect(bg).not.toBe('rgba(0, 0, 0, 0)'); + + // The overflow-x swap must not reintroduce horizontal scrolling. + const docOverflow = await page.evaluate(() => + Math.max(document.body.scrollWidth, document.documentElement.scrollWidth) - window.innerWidth + ); + expect(docOverflow).toBeLessThanOrEqual(1); + }); +}); diff --git a/app/src/pages/logs/Logs.tsx b/app/src/pages/logs/Logs.tsx index 1087c554..c9693d41 100644 --- a/app/src/pages/logs/Logs.tsx +++ b/app/src/pages/logs/Logs.tsx @@ -557,27 +557,40 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => {
- + {/* Sticky below the app header on both breakpoints. Uses the FULL header + height var — the reduced --app-header-stack subtracts the desktop + content padding and would tuck the bar under the fixed header. z-40 + stays below the header/BottomNav (z-50); Select/dropdown popovers + portal to , unaffected. pb-1/-mb-1 mirrors the filter row's own + p-1/-m-1 focus-ring allowance so content cannot peek through at the + bottom edge while scrolled. */} +
+ +
{/* Sibling of Filters and the list section so the parent's gap-6 spaces it evenly between the two. empty:hidden collapses the slot (and its gaps) From ec8fad14cca6325a79b9de35dd2fbcabb3465d35 Mon Sep 17 00:00:00 2001 From: Maciek Date: Thu, 13 Aug 2026 19:04:42 +0200 Subject: [PATCH 03/10] feat(app): surface active log filters with accents and a clear-all chip, debounce search with a clear button Signed-off-by: Maciek --- .../e2e/logs/logs-filter-visibility.spec.ts | 78 ++++++++++ app/src/__tests__/unit/Filters.test.tsx | 86 +++++++++++ app/src/__tests__/unit/NoLogs.test.tsx | 21 +++ app/src/__tests__/unit/QueryLogs.test.tsx | 124 ++++++++++++++- app/src/components/ui/dropdown-menu.tsx | 2 +- app/src/components/ui/select.tsx | 2 +- app/src/pages/logs/Filters.tsx | 145 +++++++++++++----- app/src/pages/logs/Logs.tsx | 38 ++++- app/src/pages/logs/NoLogs.tsx | 33 +++- 9 files changed, 482 insertions(+), 47 deletions(-) create mode 100644 app/src/__tests__/e2e/logs/logs-filter-visibility.spec.ts create mode 100644 app/src/__tests__/unit/Filters.test.tsx diff --git a/app/src/__tests__/e2e/logs/logs-filter-visibility.spec.ts b/app/src/__tests__/e2e/logs/logs-filter-visibility.spec.ts new file mode 100644 index 00000000..2e17946e --- /dev/null +++ b/app/src/__tests__/e2e/logs/logs-filter-visibility.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, type Page } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// Active-filter visibility (issue item 3): accent on non-default triggers, a clear-all +// chip, and a filtered empty state that offers clearing instead of the onboarding CTA. + +const profile = { id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }; + +const rows = [ + { profile_id: 'prof1', timestamp: '2026-08-13T10:00:01Z', status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'one.example.test', query_type: 'A' } }, + { profile_id: 'prof1', timestamp: '2026-08-13T10:00:00Z', status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'two.example.test', query_type: 'A' } }, +]; + +const ACCENT = /border-\[var\(--tailwind-colors-rdns-600\)\]/; + +// Register the logs route AFTER registerMocks so it is tested BEFORE the catch-all +// route (Playwright matches routes in reverse registration order). Blocked-status +// requests return no rows so the filtered empty state renders. +async function setupLogsPage(page: Page) { + await registerMocks(page, { authenticated: true, customProfiles: [profile] }); + await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + const url = new URL(route.request().url()); + const body = url.searchParams.get('status') === 'blocked' ? [] : rows; + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + }); + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').waitFor({ state: 'attached', timeout: 10000 }); +} + +const visibleByLabel = (page: Page, label: string) => + page.locator(`[aria-label="${label}"]:visible`); + +test.describe('Logs filter visibility', () => { + test('active filter accents its trigger, shows the clear chip, and the empty state clears', async ({ page }) => { + await setupLogsPage(page); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + + const statusTrigger = visibleByLabel(page, 'Filter by status'); + await expect(statusTrigger).not.toHaveClass(ACCENT); + await expect(page.getByTestId('logs-clear-filters')).toHaveCount(0); + + await statusTrigger.click(); + const blockedOption = page.getByRole('option', { name: 'Blocked' }); + // Options advertise clickability with a pointer cursor. + expect(await blockedOption.evaluate(el => getComputedStyle(el).cursor)).toBe('pointer'); + await blockedOption.click(); + + // Accent + chip appear; the blocked view is empty, so the filtered empty + // state renders with a Clear filters action (not the DNS-setup CTA). + await expect(statusTrigger).toHaveClass(ACCENT); + await expect(page.locator('[data-testid="logs-clear-filters"]:visible')).toBeVisible(); + const emptyState = page.getByTestId('logs-empty-state'); + await expect(emptyState).toBeVisible(); + await expect(emptyState.getByText(/No results for the current filters/)).toBeVisible(); + await expect(emptyState.getByRole('button', { name: /DNS Setup/ })).toHaveCount(0); + + await page.getByTestId('logs-empty-clear-filters').click(); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + await expect(statusTrigger).not.toHaveClass(ACCENT); + await expect(page.getByTestId('logs-clear-filters')).toHaveCount(0); + }); + + test('search shows a clear button and the chip only after the debounce commits', async ({ page }) => { + await setupLogsPage(page); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + + const search = page.locator('input[aria-label="Search domain or its part"]:visible'); + await search.fill('example'); + await expect(page.locator('[data-testid="logs-search-clear"]:visible')).toBeVisible(); + + // Debounce (500ms) commits the search → the clear-all chip appears. + await expect(page.locator('[data-testid="logs-clear-filters"]:visible')).toBeVisible(); + + await page.locator('[data-testid="logs-search-clear"]:visible').click(); + await expect(search).toHaveValue(''); + await expect(page.getByTestId('logs-clear-filters')).toHaveCount(0); + }); +}); diff --git a/app/src/__tests__/unit/Filters.test.tsx b/app/src/__tests__/unit/Filters.test.tsx new file mode 100644 index 00000000..9b97a9e0 --- /dev/null +++ b/app/src/__tests__/unit/Filters.test.tsx @@ -0,0 +1,86 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { describe, expect, it, vi } from 'vitest'; +import Filters from '@/pages/logs/Filters'; + +const baseProps = { + searchInputValue: '', + onSearchInputChange: vi.fn(), + onSearchCommit: vi.fn(), + onSearchClear: vi.fn(), + committedSearchValue: '', + onClearFilters: vi.fn(), + filterValue: 'all', + onFilterChange: vi.fn(), + sortValue: 'created', + onSortChange: vi.fn(), + onRefresh: vi.fn(), + timespanValue: undefined, + onTimespanChange: vi.fn(), + refreshIntervalKey: 'off' as const, + onRefreshIntervalChange: vi.fn(), + deviceIdValue: undefined, + onDeviceIdChange: vi.fn(), + availableDeviceIds: [], +}; + +const ACCENT = 'border-[var(--tailwind-colors-rdns-600)]'; + +describe('Filters', () => { + it('gives every select trigger an accessible name', () => { + render(); + expect(screen.getByLabelText('Filter by status')).toBeInTheDocument(); + expect(screen.getByLabelText('Filter by device')).toBeInTheDocument(); + expect(screen.getByLabelText('Sort logs')).toBeInTheDocument(); + expect(screen.getByLabelText('Filter by timespan')).toBeInTheDocument(); + }); + + it('shows no accents and no clear chip at defaults', () => { + render(); + for (const label of ['Filter by status', 'Filter by device', 'Sort logs', 'Filter by timespan']) { + expect(screen.getByLabelText(label).className).not.toContain(ACCENT); + } + expect(screen.queryByTestId('logs-clear-filters')).not.toBeInTheDocument(); + }); + + it('accents exactly the active triggers and shows the clear chip', () => { + render(); + expect(screen.getByLabelText('Filter by status').className).toContain(ACCENT); + expect(screen.getByLabelText('Filter by timespan').className).toContain(ACCENT); + expect(screen.getByLabelText('Filter by device').className).not.toContain(ACCENT); + expect(screen.getByLabelText('Sort logs').className).not.toContain(ACCENT); + + const chip = screen.getByTestId('logs-clear-filters'); + fireEvent.click(chip); + expect(baseProps.onClearFilters).toHaveBeenCalled(); + }); + + it('a committed search shows the clear chip; uncommitted typing does not', () => { + const { rerender } = render(); + expect(screen.queryByTestId('logs-clear-filters')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId('logs-clear-filters')).toBeInTheDocument(); + }); + + it('search inputs expose a clear button only when text is present', () => { + const { rerender } = render(); + expect(screen.queryAllByTestId('logs-search-clear')).toHaveLength(0); + + rerender(); + // One per breakpoint instance (mobile row + desktop row). + const clears = screen.getAllByTestId('logs-search-clear'); + expect(clears.length).toBeGreaterThan(0); + fireEvent.click(clears[0]); + expect(baseProps.onSearchClear).toHaveBeenCalled(); + }); + + it('search commits on Enter, and blur alone does not commit', () => { + render(); + const inputs = screen.getAllByLabelText('Search domain or its part'); + fireEvent.blur(inputs[0]); + expect(baseProps.onSearchCommit).not.toHaveBeenCalled(); + fireEvent.keyDown(inputs[0], { key: 'Enter' }); + expect(baseProps.onSearchCommit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/__tests__/unit/NoLogs.test.tsx b/app/src/__tests__/unit/NoLogs.test.tsx index 4d4439d2..1d661c78 100644 --- a/app/src/__tests__/unit/NoLogs.test.tsx +++ b/app/src/__tests__/unit/NoLogs.test.tsx @@ -39,4 +39,25 @@ describe('NoLogs empty state', () => { expect(screen.queryByText(/Set up modDNS on your devices/i)).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /DNS Setup/i })).not.toBeInTheDocument(); }); + + it('renders filters empty state with a Clear filters action instead of the setup CTA', () => { + const onClearFilters = vi.fn(); + render(); + + expect(screen.getByText(/No matching logs/i)).toBeInTheDocument(); + expect(screen.getByText(/No results for the current filters/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /DNS Setup/i })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('logs-empty-clear-filters')); + expect(onClearFilters).toHaveBeenCalledTimes(1); + expect(navigateMock).not.toHaveBeenCalled(); + }); + + it('offers Clear filters for an active search too, keeping the search copy', () => { + const onClearFilters = vi.fn(); + render(); + + expect(screen.getByText(/No logs match your search/i)).toBeInTheDocument(); + expect(screen.getByTestId('logs-empty-clear-filters')).toBeInTheDocument(); + }); }); diff --git a/app/src/__tests__/unit/QueryLogs.test.tsx b/app/src/__tests__/unit/QueryLogs.test.tsx index 14696599..8a1c82f8 100644 --- a/app/src/__tests__/unit/QueryLogs.test.tsx +++ b/app/src/__tests__/unit/QueryLogs.test.tsx @@ -65,8 +65,11 @@ vi.mock("@/pages/logs/Filters", () => ({ onRefresh, onRefreshIntervalChange, isRefreshing, - }: { searchInputValue: string; onSearchInputChange?: (v: string) => void; onSearchCommit?: () => void; onFilterChange?: (v: string) => void; onSortChange?: (v: string) => void; onTimespanChange?: (v: string) => void; onDeviceIdChange?: (v: string) => void; onRefresh?: () => void; onRefreshIntervalChange?: (v: string) => void; isRefreshing?: boolean }) => ( -
+ onSearchClear, + onClearFilters, + committedSearchValue, + }: { searchInputValue: string; onSearchInputChange?: (v: string) => void; onSearchCommit?: () => void; onFilterChange?: (v: string) => void; onSortChange?: (v: string) => void; onTimespanChange?: (v: string) => void; onDeviceIdChange?: (v: string) => void; onRefresh?: () => void; onRefreshIntervalChange?: (v: string) => void; isRefreshing?: boolean; onSearchClear?: () => void; onClearFilters?: () => void; committedSearchValue?: string }) => ( +
({ + + @@ -585,6 +590,121 @@ describe("QueryLogs", () => { await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(100)); }); + test("search commits 500ms after typing stops, not before", async () => { + vi.useFakeTimers(); + try { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(2, 0) }); + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + + act(() => { + fireEvent.change(screen.getByTestId("search-input"), { target: { value: "example" } }); + }); + // Just under the debounce window: nothing committed yet. + await act(async () => { + await vi.advanceTimersByTimeAsync(450); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + // Window elapses → one fetch with the search term. + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, 1, 100, undefined, undefined, undefined, "example", "created" + ); + } finally { + vi.useRealTimers(); + } + }); + + test("typing keeps postponing the debounce; Enter commits immediately", async () => { + vi.useFakeTimers(); + try { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(2, 0) }); + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + + // Two keystrokes 300ms apart: the first debounce window never completes. + act(() => { + fireEvent.change(screen.getByTestId("search-input"), { target: { value: "exa" } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + act(() => { + fireEvent.change(screen.getByTestId("search-input"), { target: { value: "example" } }); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + + // Enter (stub's commit button) applies without waiting. + act(() => { + fireEvent.click(screen.getByTestId("commit-search")); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, 1, 100, undefined, undefined, undefined, "example", "created" + ); + // The trailing debounce is a no-op after the manual commit. + await act(async () => { + await vi.advanceTimersByTimeAsync(600); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + test("clear search empties both pending and committed values", async () => { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(2, 0) }); + render(); + await waitFor(() => expect(queryLogsMock).toHaveBeenCalledTimes(1)); + + fireEvent.change(screen.getByTestId("search-input"), { target: { value: "example" } }); + fireEvent.click(screen.getByTestId("commit-search")); + await waitFor(() => expect(queryLogsMock).toHaveBeenCalledTimes(2)); + + fireEvent.click(screen.getByTestId("search-clear")); + await waitFor(() => expect(queryLogsMock).toHaveBeenCalledTimes(3)); + expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, 1, 100, undefined, undefined, undefined, undefined, "created" + ); + expect((screen.getByTestId("search-input") as HTMLInputElement).value).toBe(""); + }); + + test("clear filters resets every request parameter to defaults", async () => { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(2, 0) }); + render(); + await waitFor(() => expect(queryLogsMock).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByTestId("filter-blocked")); + fireEvent.click(screen.getByTestId("device-select")); + fireEvent.click(screen.getByTestId("sort-domain")); + fireEvent.change(screen.getByTestId("search-input"), { target: { value: "foo" } }); + fireEvent.click(screen.getByTestId("commit-search")); + await waitFor(() => expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, 1, 100, "blocked", undefined, "device-1", "foo", "domain" + )); + + fireEvent.click(screen.getByTestId("clear-filters")); + await waitFor(() => expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, 1, 100, undefined, undefined, undefined, undefined, "created" + )); + expect((screen.getByTestId("search-input") as HTMLInputElement).value).toBe(""); + }); + test("shows not active state when logs disabled", async () => { const disabledProfile = { ...baseProfile, profile_id: "profile-disabled", id: "profile-disabled", settings: { logs: { enabled: false } } }; queryLogsMock.mockResolvedValue({ status: 200, data: [] }); diff --git a/app/src/components/ui/dropdown-menu.tsx b/app/src/components/ui/dropdown-menu.tsx index 7ad7a90c..7817b267 100644 --- a/app/src/components/ui/dropdown-menu.tsx +++ b/app/src/components/ui/dropdown-menu.tsx @@ -126,7 +126,7 @@ function DropdownMenuRadioItem({ void; // updates uncontrolled typing state onSearchCommit: () => void; // commit the current input value to trigger request + onSearchClear: () => void; // empty the input AND the committed value + /** Last committed search — drives the clear-filters chip, never the uncommitted typing. */ + committedSearchValue: string; + onClearFilters: () => void; filterValue: string; onFilterChange: (value: string) => void; sortValue: string; @@ -42,6 +46,48 @@ interface FiltersProps { availableDeviceIds: string[]; } +// Shared search input (rendered in the mobile row and the desktop row). Commits on +// Enter or after the owner's debounce — there is deliberately no commit-on-blur (the +// old behavior committed on blur only below 1024px, measured at event time, which made +// desktop and mobile behave differently for no discernible reason). +const LogsSearchInput = ({ + value, + onChange, + onCommit, + onClear, +}: { + value: string; + onChange: (value: string) => void; + onCommit: () => void; + onClear: () => void; +}): JSX.Element => ( +
+
+ +
+ onChange(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { onCommit(); e.currentTarget.blur(); } }} + /> + {value.length > 0 && ( + + )} +
+); + // Grafana-style split refresh control, rendered in the mobile search row and at the end // of the desktop filter row. Left half: one-shot refresh (never touches the loop). Right // half: auto-refresh interval menu; while an interval is active its compact label shows @@ -124,6 +170,9 @@ const Filters = ({ searchInputValue, onSearchInputChange, onSearchCommit, + onSearchClear, + committedSearchValue, + onClearFilters, filterValue, onFilterChange, sortValue, @@ -137,27 +186,37 @@ const Filters = ({ deviceIdValue, onDeviceIdChange, availableDeviceIds, -}: FiltersProps): JSX.Element => ( +}: FiltersProps): JSX.Element => { + // Below `md` the Select values are hidden, so the accent border/icon is the ONLY + // signal that a filter narrows the list. + const statusActive = filterValue !== "all"; + const deviceActive = deviceIdValue !== undefined; + const sortActive = sortValue !== "created"; + const timespanActive = timespanValue !== undefined && timespanValue !== "all"; + const searchCommitted = committedSearchValue.trim().length > 0; + const anyActive = statusActive || deviceActive || sortActive || timespanActive || searchCommitted; + // The base SelectTrigger's focus-visible ring + border-ring fires on Radix's + // programmatic refocus after picking an option, painting a gray outline over the + // accent border — suppress it and keep the border tracking the active state. + // Active accent matches the query-log cards' hover/open outline (full rdns-600 on + // light, /40 on dark) so the two surfaces share one visual language. + const triggerBorder = (active: boolean) => + active + ? "border-[var(--tailwind-colors-rdns-600)] dark:border-[var(--tailwind-colors-rdns-600)]/40 focus-visible:border-[var(--tailwind-colors-rdns-600)] dark:focus-visible:border-[var(--tailwind-colors-rdns-600)]/40 focus-visible:ring-0" + : "border-[var(--tailwind-colors-slate-600)] focus-visible:border-[var(--tailwind-colors-slate-600)] focus-visible:ring-0"; + const iconTint = (active: boolean) => (active ? "text-[var(--tailwind-colors-rdns-600)]" : ""); + return ( <> {/* Tablet layout adjustment: two-row layout persists through md (tablets). Desktop (>=lg) collapses to one row. */}
{/* Row 1: search + refresh (mobile). Desktop: all inline revert -> wrap both rows into one flex row via md:hidden/md:flex patterns */}
-
-
- -
- onSearchInputChange(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { onSearchCommit(); e.currentTarget.blur(); } }} - onBlur={() => { if (window.innerWidth < 1024) onSearchCommit(); }} - /> -
+ {/* Desktop search (hidden on mobile) */} -
-
- -
- + onSearchInputChange(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { onSearchCommit(); e.currentTarget.blur(); } }} - onBlur={() => { if (window.innerWidth < 1024) onSearchCommit(); }} + onChange={onSearchInputChange} + onCommit={onSearchCommit} + onClear={onSearchClear} />
{/* Query filter */} - +
- +
@@ -243,9 +295,9 @@ const Filters = ({ value={timespanValue ?? "all"} onValueChange={val => onTimespanChange(val === "all" ? undefined : val)} > - +
- +
@@ -259,6 +311,22 @@ const Filters = ({ + {/* Clear-all chip: appears once anything narrows the list (a non-default + select OR a committed search — never uncommitted typing). */} + {anyActive && ( + + )} + {/* Desktop refresh controls (hidden on mobile second row) */}
-); + ); +}; export default Filters; \ No newline at end of file diff --git a/app/src/pages/logs/Logs.tsx b/app/src/pages/logs/Logs.tsx index 8c7b9c55..1087c554 100644 --- a/app/src/pages/logs/Logs.tsx +++ b/app/src/pages/logs/Logs.tsx @@ -242,6 +242,35 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { setCommittedSearchValue(prev => prev === searchInputValue ? prev : searchInputValue); }, [searchInputValue]); + // Debounce-commit: the search applies 500ms after typing stops. Enter still commits + // immediately (Filters calls commitSearch directly); its equality guard turns the + // trailing debounce into a no-op afterwards. + useEffect(() => { + const timer = setTimeout(commitSearch, 500); + return () => clearTimeout(timer); + }, [searchInputValue, commitSearch]); + + // Not routed through commitSearch — it closes over the pre-clear input value. + const handleSearchClear = useCallback(() => { + setSearchInputValue(""); + setCommittedSearchValue(""); + }, []); + + const hasNonDefaultFilters = + filterValue !== "all" || + deviceIdValue !== undefined || + sortValue !== "created" || + (timespanValue !== undefined && timespanValue !== "all"); + + const handleClearFilters = useCallback(() => { + setFilterValue("all"); + setSortValue("created"); + setTimespanValue(undefined); + setDeviceIdValue(undefined); + setSearchInputValue(""); + setCommittedSearchValue(""); + }, []); + const toggleCardExpanded = useCallback((identity: string) => { setExpandedKeys(prev => { const next = new Set(prev); @@ -532,6 +561,9 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { searchInputValue={searchInputValue} onSearchInputChange={setSearchInputValue} onSearchCommit={commitSearch} + onSearchClear={handleSearchClear} + committedSearchValue={committedSearchValue} + onClearFilters={handleClearFilters} filterValue={filterValue} onFilterChange={setFilterValue} sortValue={sortValue} @@ -582,7 +614,11 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => {
- 0} /> + 0} + hasActiveFilters={hasNonDefaultFilters} + onClearFilters={handleClearFilters} + />
diff --git a/app/src/pages/logs/NoLogs.tsx b/app/src/pages/logs/NoLogs.tsx index 33a4d92d..46726de6 100644 --- a/app/src/pages/logs/NoLogs.tsx +++ b/app/src/pages/logs/NoLogs.tsx @@ -6,6 +6,10 @@ import { useNavigate } from "react-router-dom"; interface NoLogsProps { isSearchActive?: boolean; + /** A non-default status/device/sort/timespan filter is applied. */ + hasActiveFilters?: boolean; + /** Resets every filter and the search; enables the "Clear filters" action. */ + onClearFilters?: () => void; } interface EmptyStateContent { @@ -14,7 +18,7 @@ interface EmptyStateContent { buttonText?: string; } -const emptyStateVariants: Record<"default" | "search", EmptyStateContent> = { +const emptyStateVariants: Record<"default" | "search" | "filters", EmptyStateContent> = { default: { title: "No logs to display", description: "Set up modDNS on your devices to start analysing queries.", @@ -24,11 +28,22 @@ const emptyStateVariants: Record<"default" | "search", EmptyStateContent> = { title: "No matching logs", description: "No logs match your search. Try updating the keywords or filters.", }, + filters: { + title: "No matching logs", + description: "No results for the current filters — try clearing them.", + }, }; -const NoLogs = ({ isSearchActive = false }: NoLogsProps): JSX.Element => { +const NoLogs = ({ isSearchActive = false, hasActiveFilters = false, onClearFilters }: NoLogsProps): JSX.Element => { const navigate = useNavigate(); - const emptyStateData = isSearchActive ? emptyStateVariants.search : emptyStateVariants.default; + // The DNS-setup onboarding CTA is only correct when the list is empty with NOTHING + // narrowing it — an empty filtered view means "no matches", not "not set up yet". + const isFiltered = isSearchActive || hasActiveFilters; + const emptyStateData = isSearchActive + ? emptyStateVariants.search + : hasActiveFilters + ? emptyStateVariants.filters + : emptyStateVariants.default; return ( + Clear filters + + )} + {!isFiltered && emptyStateData.buttonText && ( +
+ )} + {/* Quiet end-of-list marker: without it the skeletons just stop + and a finished list is indistinguishable from a stalled one. */} + {!hasMore && !loading && !error && logs.length > 0 && ( +
+ End of logs
)}