diff --git a/app/src/__tests__/e2e/logs/logs-refresh-controls.spec.ts b/app/src/__tests__/e2e/logs/logs-refresh-controls.spec.ts new file mode 100644 index 00000000..75e55073 --- /dev/null +++ b/app/src/__tests__/e2e/logs/logs-refresh-controls.spec.ts @@ -0,0 +1,125 @@ +import { test, expect, type Page } from '@playwright/test'; +import { registerMocks } from '../../mocks/registerMocks'; + +// Refresh controls on the Query Logs page: the icon button is a one-shot refresh and +// the labeled "Auto" toggle owns the 10s loop. Interval/pill mechanics live in unit +// tests (QueryLogs.test.tsx); here we pin the wire-level behavior and accessibility. + +const profile = { id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }; + +const logItem = (i: number, domain: string) => ({ + profile_id: 'prof1', + timestamp: `2026-08-12T10:00:${(59 - i).toString().padStart(2, '0')}Z`, + status: 'processed', + protocol: 'dns', + device_id: `device-${i}`, + client_ip: `10.0.0.${i}`, + dns_request: { domain, 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). The catch-all in +// registerMocks matches `/api/v1/profiles` and would otherwise shadow this endpoint. +// `respond` is re-evaluated per request (never keyed on call count — StrictMode +// double-fires the mount fetch in dev, so call indices are not deterministic). +async function setupLogsPage(page: Page, respond: () => object[]) { + await registerMocks(page, { authenticated: true, customProfiles: [profile] }); + let calls = 0; + await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + calls++; + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(respond()) }); + }); + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').waitFor({ state: 'attached', timeout: 10000 }); + return { logsCalls: () => calls }; +} + +// Two instances render (mobile row / desktop row); only one is visible per breakpoint. +const visibleControl = (page: Page, testId: string) => + page.locator(`[data-testid="${testId}"]:visible`); + +test.describe('Logs refresh controls', () => { + test('split button halves stay equal height at tablet width', async ({ page }) => { + // The Button default size carries sm:h-9, which silently shrinks the interval + // trigger below the icon half on 640-1024px viewports unless pinned. + await page.setViewportSize({ width: 834, height: 1112 }); + await setupLogsPage(page, () => [logItem(1, 'one.example.test')]); + const refresh = await visibleControl(page, 'logs-refresh-button').boundingBox(); + const trigger = await visibleControl(page, 'logs-refresh-interval-trigger').boundingBox(); + expect(refresh && trigger && refresh.height === trigger.height && refresh.y === trigger.y).toBe(true); + }); + + test('refresh button is a one-shot refresh with an accessible name', async ({ page }) => { + const { logsCalls } = await setupLogsPage(page, () => [logItem(1, 'one.example.test')]); + const initialCalls = logsCalls(); + + const refresh = visibleControl(page, 'logs-refresh-button'); + await expect(refresh).toHaveAccessibleName('Refresh query logs'); + + await refresh.click(); + await expect.poll(logsCalls).toBe(initialCalls + 1); + + // The click is acknowledged with at least one full rotation even when the + // response lands instantly... + await expect(refresh.locator('svg')).toHaveClass(/animate-spin/); + // ...then the spin stops: one-shot means no lingering animation and no + // auto-refresh mode (no interval label on the split button). + await expect(refresh.locator('svg')).not.toHaveClass(/animate-spin|animate-\[/, { timeout: 3000 }); + await expect(page.getByTestId('logs-refresh-interval-label')).toHaveCount(0); + expect(logsCalls()).toBe(initialCalls + 1); + }); + + test('interval menu enables live mode and stages new entries behind the pill', async ({ page }) => { + const initial = [logItem(1, 'one.example.test'), logItem(2, 'two.example.test')]; + const fresh = logItem(0, 'fresh.example.test'); + let dataset = initial; + await setupLogsPage(page, () => dataset); + + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + // From here on, one new entry sits above the known head — the immediate tick + // fired by enabling auto-refresh should stage it, not apply it. + dataset = [fresh, ...initial]; + + const intervalTrigger = visibleControl(page, 'logs-refresh-interval-trigger'); + await expect(intervalTrigger).toHaveAccessibleName('Auto-refresh interval'); + + // The menu offers the full interval set. + await intervalTrigger.click(); + for (const key of ['off', 'auto', '5s', '10s', '15s', '30s', '60s']) { + await expect(page.getByTestId(`logs-refresh-interval-${key}`)).toBeVisible(); + } + // Desktop: the menu opens down-right (extends past the trigger's right edge, + // into the page margin) so it doesn't drop over the quick-rule column at the + // right edge of the cards below. Radix may clamp the exact left edge to keep + // the menu inside the viewport, so assert the direction, not exact alignment. + if (test.info().project.name === 'chromium-desktop') { + const menuBox = await page.getByTestId('logs-refresh-interval-auto').boundingBox(); + const triggerBox = await intervalTrigger.boundingBox(); + expect(menuBox && triggerBox && menuBox.x + menuBox.width > triggerBox.x + triggerBox.width + 10).toBe(true); + } + await page.getByTestId('logs-refresh-interval-auto').click(); + + // Live cues: compact label on the split button + continuously spinning icon. + await expect(visibleControl(page, 'logs-refresh-interval-label')).toHaveText('Auto'); + await expect(visibleControl(page, 'logs-refresh-button').locator('svg')).toHaveClass(/animate-\[spin_3s_linear_infinite\]/); + + // The immediate tick stages the new entry — the list itself must not change. + const pill = page.getByTestId('logs-new-queries-pill'); + await expect(pill).toBeVisible(); + await expect(pill).toHaveText(/1 new query/); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + // Clickability affordance: pointer cursor on hover. + expect(await pill.evaluate(el => getComputedStyle(el).cursor)).toBe('pointer'); + + // Revealing prepends without a reload. + await pill.click(); + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(3); + await expect(pill).toHaveCount(0); + + // Off stops live mode: label disappears, icon stops spinning. + await intervalTrigger.click(); + await page.getByTestId('logs-refresh-interval-off').click(); + await expect(page.getByTestId('logs-refresh-interval-label')).toHaveCount(0); + await expect(visibleControl(page, 'logs-refresh-button').locator('svg')).not.toHaveClass(/animate-spin|animate-\[/); + }); +}); diff --git a/app/src/__tests__/unit/QueryLogCard.test.tsx b/app/src/__tests__/unit/QueryLogCard.test.tsx index 0a802350..47a648b2 100644 --- a/app/src/__tests__/unit/QueryLogCard.test.tsx +++ b/app/src/__tests__/unit/QueryLogCard.test.tsx @@ -160,6 +160,48 @@ describe('QueryLogCard whole-card expansion', () => { expect(screen.getByTestId('querylog-detail-domain')).toHaveTextContent('Domain logging disabled'); }); + test('controlled mode renders the expanded prop and reports toggles without flipping itself', () => { + const onToggleExpanded = vi.fn(); + const { rerender } = render( + + ); + const toggle = screen.getByTestId('querylog-card-toggle'); + fireEvent.click(toggle); + expect(onToggleExpanded).toHaveBeenCalledTimes(1); + // State is owned by the parent — the card must not expand on its own. + expect(screen.getByTestId('querylog-expanded-panel')).toHaveAttribute('data-expanded', 'false'); + + rerender(); + expect(screen.getByTestId('querylog-expanded-panel')).toHaveAttribute('data-expanded', 'true'); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + test('controlled mode fires onExpand only when opening', () => { + const onExpand = vi.fn(); + const { rerender } = render( + {}} onExpand={onExpand} /> + ); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + expect(onExpand).toHaveBeenCalledTimes(1); + + rerender( + {}} onExpand={onExpand} /> + ); + // Collapsing an open card is not an "expand". + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + expect(onExpand).toHaveBeenCalledTimes(1); + }); + + test('animateEntry plays the entry animation with a reduced-motion escape', () => { + const { container, rerender } = render(); + const root = container.firstElementChild as HTMLElement; + expect(root.className).toContain('animate-in'); + expect(root.className).toContain('motion-reduce:animate-none'); + + rerender(); + expect((container.firstElementChild as HTMLElement).className).not.toContain('animate-in'); + }); + test('there is no visible chevron indicator', () => { render(); expect(screen.queryByTestId('querylog-expand-indicator')).not.toBeInTheDocument(); diff --git a/app/src/__tests__/unit/QueryLogs.test.tsx b/app/src/__tests__/unit/QueryLogs.test.tsx index 4bbcdeb8..14696599 100644 --- a/app/src/__tests__/unit/QueryLogs.test.tsx +++ b/app/src/__tests__/unit/QueryLogs.test.tsx @@ -33,7 +33,7 @@ vi.mock("@/pages/logs/QuickRuleSheet", () => ({ vi.mock("@/pages/logs/QueryLogCard", () => ({ __esModule: true, - default: function MockQueryLogCard({ log, onQuickRule, lastLogRef, isLast }: { log: { status: string; dns_request?: { domain: string } }; onQuickRule?: (domain: string, action: string) => void; lastLogRef?: (el: HTMLDivElement) => void; isLast?: boolean }) { + default: function MockQueryLogCard({ log, onQuickRule, lastLogRef, isLast, animateEntry }: { log: { status: string; dns_request?: { domain: string } }; onQuickRule?: (domain: string, action: string) => void; lastLogRef?: (el: HTMLDivElement) => void; isLast?: boolean; animateEntry?: boolean }) { React.useEffect(() => { if (lastLogRef) { const el = document.createElement("div"); @@ -41,7 +41,7 @@ vi.mock("@/pages/logs/QueryLogCard", () => ({ } }, [lastLogRef, isLast]); return ( -
+
- + + +
), })); @@ -150,6 +153,16 @@ const makeLog = (overrides: Record = {}) => ({ ...overrides, }); +// Distinct domains + timestamps so entries neither consolidate nor collide in the +// background-tick diff. `offset` keeps batches disjoint across mock responses. +const distinctLogs = (count: number, offset: number) => + Array.from({ length: count }).map((_, i) => + makeLog({ + dns_request: { domain: `d${offset + i}.example.com` }, + timestamp: `2024-01-01T00:${Math.floor((offset + i) / 60).toString().padStart(2, "0")}:${((offset + i) % 60).toString().padStart(2, "0")}Z`, + }) + ); + describe("QueryLogs", () => { beforeEach(() => { vi.useRealTimers(); @@ -264,26 +277,18 @@ describe("QueryLogs", () => { await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(2)); }); - test("keeps cards visible when auto-refresh is toggled and pagination fires during refresh", async () => { - // Regression test for the auto-refresh "invisible cards" bug: the list container was - // faded to opacity-0 on every page-1 refresh and only restored by a 100ms setTimeout - // that the fetch effect's cleanup cancels. An IntersectionObserver page bump inside + test("keeps cards visible when a manual refresh and pagination overlap", async () => { + // Regression test for the "invisible cards" bug: the list container is faded to + // opacity-0 on every page-1 refresh and only restored by a 100ms setTimeout that + // the fetch effect's cleanup cancels. An IntersectionObserver page bump inside // that window (opacity-0 elements still intersect) left the cards mounted and // clickable but permanently invisible. vi.useFakeTimers(); try { - const distinctLogs = (count: number, offset: number) => - Array.from({ length: count }).map((_, i) => - makeLog({ - dns_request: { domain: `d${offset + i}.example.com` }, - timestamp: `2024-01-01T00:${Math.floor((offset + i) / 60).toString().padStart(2, "0")}:${((offset + i) % 60).toString().padStart(2, "0")}Z`, - }) - ); - // Call 1: initial load (page 1, limit 100). Call 2: refresh triggered by the - // auto-refresh toggle (page 1, limit 25 → full page, so hasMore recomputes true). - // Call 3: the observer-driven page-2 fetch. + // Call 1: initial load (page 1, limit 100 → full page, hasMore true). + // Call 2: the manual one-shot refresh. Call 3: the observer-driven page-2 fetch. queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(100, 0) }); - queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(25, 100) }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(100, 200) }); queryLogsMock.mockResolvedValueOnce({ status: 200, data: [] }); render(); @@ -295,7 +300,7 @@ describe("QueryLogs", () => { expect(screen.getAllByTestId("log-card").length).toBeGreaterThan(0); act(() => { - fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + fireEvent.click(screen.getByTestId("refresh")); }); // Previous data must stay on screen while the refresh is in flight — no blank flash. expect(screen.getAllByTestId("log-card").length).toBeGreaterThan(0); @@ -312,10 +317,9 @@ describe("QueryLogs", () => { act(() => { MockIntersectionObserver.lastInstance?.trigger([{ isIntersecting: true } as IntersectionObserverEntry]); }); - // Let everything settle (stay below the 10s auto-refresh interval). Two advances: - // the page-2 fetch resolves during the first; the fade-in timer it schedules is - // created in a passive effect flushed at the end of that act block, so a second - // advance is needed for it to fire. + // Let everything settle. Two advances: the page-2 fetch resolves during the + // first; the fade-in timer it schedules is created in a passive effect flushed + // at the end of that act block, so a second advance is needed for it to fire. await act(async () => { await vi.advanceTimersByTimeAsync(500); }); @@ -332,6 +336,255 @@ describe("QueryLogs", () => { } }); + // tableRef: query-logs-refresh-behaviour #C1 + test("manual refresh refetches page 1 with limit 100 and replaces the list", async () => { + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(5, 0) }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(2, 100) }); + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(5)); + + fireEvent.click(screen.getByTestId("refresh")); + + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(2)); + expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, + 1, + 100, + undefined, + undefined, + undefined, + undefined, + "created" + ); + }); + + // tableRef: query-logs-refresh-behaviour #C2 #T4 #P1 + test("auto-refresh stages new entries behind a pill instead of replacing the list", async () => { + const initial = distinctLogs(5, 0); + const fresh = distinctLogs(2, 100); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: initial }); + // Immediate tick fired by enabling auto-refresh: two new entries above the known head. + queryLogsMock.mockResolvedValueOnce({ status: 200, data: [...fresh, ...initial] }); + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(5)); + // Let the initial 100ms fade-in release so the opacity assertion below can only + // trip on a fade restarted by the tick. + await waitFor(() => + expect(screen.getByTestId("logs-scroll-container").querySelector(".opacity-0")).toBeNull() + ); + + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + + const pill = await screen.findByTestId("logs-new-queries-pill"); + expect(pill).toHaveTextContent("2 new queries"); + // The tick must not have touched the list: same cards, no fade restart. + expect(screen.getAllByTestId("log-card")).toHaveLength(5); + expect(screen.getByTestId("logs-scroll-container").querySelector(".opacity-0")).toBeNull(); + + fireEvent.click(pill); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(7)); + expect(screen.queryByTestId("logs-new-queries-pill")).toBeNull(); + // Revealing staged entries is purely client-side — no extra request. + expect(queryLogsMock).toHaveBeenCalledTimes(2); + // Only the revealed entries play the entry animation — the prepend remounts + // every card, so pre-existing rows must not re-animate. + const animateFlags = screen.getAllByTestId("log-card").map(card => card.getAttribute("data-animate-entry")); + expect(animateFlags).toEqual(["true", "true", "false", "false", "false", "false", "false"]); + }); + + // tableRef: query-logs-refresh-behaviour #P3 + test("clears staged entries when a filter changes", async () => { + const initial = distinctLogs(5, 0); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: initial }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: [...distinctLogs(1, 100), ...initial] }); + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(3, 200) }); + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(5)); + + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + await screen.findByTestId("logs-new-queries-pill"); + + fireEvent.click(screen.getByTestId("filter-blocked")); + await waitFor(() => expect(screen.queryByTestId("logs-new-queries-pill")).toBeNull()); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(3)); + }); + + // tableRef: query-logs-refresh-behaviour #C1 + test("manual refresh spins the icon for at least half a second even on instant responses", async () => { + vi.useFakeTimers(); + try { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(3, 0) }); + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + expect(screen.getByTestId("filters")).toHaveAttribute("data-refreshing", "false"); + + act(() => { + fireEvent.click(screen.getByTestId("refresh")); + }); + // The response resolves in microtasks, yet the spin must hold... + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); + expect(screen.getByTestId("filters")).toHaveAttribute("data-refreshing", "true"); + // ...until the 500ms half-rotation minimum elapses. + await act(async () => { + await vi.advanceTimersByTimeAsync(350); + }); + expect(screen.getByTestId("filters")).toHaveAttribute("data-refreshing", "false"); + } finally { + vi.useRealTimers(); + } + }); + + // tableRef: query-logs-refresh-behaviour #C2 + test("ticks at the selected interval and stops when switched off", async () => { + vi.useFakeTimers(); + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(false); + try { + queryLogsMock.mockResolvedValue({ status: 200, data: distinctLogs(3, 0) }); + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + + // Selecting 5s fires the immediate enable tick (call 2)... + act(() => { + fireEvent.click(screen.getByTestId("refresh-interval-5s")); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + + // ...then one tick per 5s window. + await act(async () => { + await vi.advanceTimersByTimeAsync(5100); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(3); + + // Off stops the loop entirely. + act(() => { + fireEvent.click(screen.getByTestId("refresh-interval-off")); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(20000); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(3); + } finally { + hiddenSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + // tableRef: query-logs-refresh-behaviour #T1 + test("skips background ticks while the tab is hidden and catches up on return", async () => { + vi.useFakeTimers(); + const hiddenSpy = vi.spyOn(document, "hidden", "get").mockReturnValue(false); + try { + const initial = distinctLogs(5, 0); + queryLogsMock.mockResolvedValue({ status: 200, data: initial }); + + render(); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(1); + + // Enable auto-refresh: the immediate tick is call 2. + act(() => { + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + }); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + + // Hidden tab: interval ticks self-skip without fetching. + hiddenSpy.mockReturnValue(true); + await act(async () => { + await vi.advanceTimersByTimeAsync(25000); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(2); + + // Back to visible: the visibilitychange handler fires an immediate catch-up tick. + hiddenSpy.mockReturnValue(false); + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + await vi.advanceTimersByTimeAsync(0); + }); + expect(queryLogsMock).toHaveBeenCalledTimes(3); + } finally { + hiddenSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + // tableRef: query-logs-refresh-behaviour #T2 + test("falls back to a full replace on tick when sorted by domain", async () => { + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(5, 0) }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(4, 100) }); // sort-change refetch + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(2, 200) }); // tick fallback replace + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(5)); + + fireEvent.click(screen.getByTestId("sort-domain")); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(4)); + + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + // Non-temporal sort: the tick replaces the list wholesale; nothing is staged. + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(2)); + expect(screen.queryByTestId("logs-new-queries-pill")).toBeNull(); + expect(queryLogsMock).toHaveBeenLastCalledWith( + baseProfile.profile_id, + 1, + 100, + undefined, + undefined, + undefined, + undefined, + "domain" + ); + }); + + // tableRef: query-logs-refresh-behaviour #T3 + test("applies tick data directly when the list is empty", async () => { + queryLogsMock.mockResolvedValueOnce({ status: 200, data: [] }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(3, 0) }); + + render(); + await screen.findByTestId("logs-empty-state"); + + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(3)); + expect(screen.queryByTestId("logs-new-queries-pill")).toBeNull(); + }); + + // tableRef: query-logs-refresh-behaviour #T5 #P2 + test("shows 100+ and reloads when the tick shares nothing with the list", async () => { + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(5, 0) }); + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(100, 100) }); // full page, no overlap + queryLogsMock.mockResolvedValueOnce({ status: 200, data: distinctLogs(100, 100) }); // reload after pill click + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(5)); + + fireEvent.click(screen.getByTestId("auto-refresh-toggle")); + const pill = await screen.findByTestId("logs-new-queries-pill"); + expect(pill).toHaveTextContent("100+ new queries"); + + // A gapped prepend would misorder the list — the pill triggers a full reload instead. + fireEvent.click(pill); + await waitFor(() => expect(queryLogsMock).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(100)); + }); + 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/__tests__/unit/lib/consolidateLogs.test.ts b/app/src/__tests__/unit/lib/consolidateLogs.test.ts index b2df2947..76661834 100644 --- a/app/src/__tests__/unit/lib/consolidateLogs.test.ts +++ b/app/src/__tests__/unit/lib/consolidateLogs.test.ts @@ -161,4 +161,18 @@ describe('consolidateLogs', () => { expect(g.queryTypes).toEqual(['A']); expect(g.representative.dns_request?.domain).toBe('a.com'); }); + + it('identity is stable when entries are prepended above the group, key is not', () => { + const existing = [ + log({ domain: 'stable.com', query_type: 'A', timestamp: '2026-06-15T10:00:00.000Z' }), + ]; + const prepended = [ + log({ domain: 'newer.com', query_type: 'A', timestamp: '2026-06-15T10:00:05.000Z' }), + ...existing, + ]; + const before = consolidateLogs(existing)[0]; + const after = consolidateLogs(prepended)[1]; + expect(after.identity).toBe(before.identity); + expect(after.key).not.toBe(before.key); + }); }); diff --git a/app/src/__tests__/unit/lib/queryLogsDiff.test.ts b/app/src/__tests__/unit/lib/queryLogsDiff.test.ts new file mode 100644 index 00000000..7bfdd400 --- /dev/null +++ b/app/src/__tests__/unit/lib/queryLogsDiff.test.ts @@ -0,0 +1,89 @@ +import { describe, test, expect } from "vitest"; +import { computeNewQueryLogs, logIdentity } from "@/lib/queryLogsDiff"; +import type { ModelQueryLog } from "@/api/client"; + +const log = (overrides: Partial & { domain?: string } = {}): ModelQueryLog => { + const { domain, ...rest } = overrides; + return { + profile_id: "profile-1", + timestamp: "2024-01-01T00:00:00Z", + status: "processed", + dns_request: { domain: domain ?? "example.com", query_type: "A" }, + device_id: "device-1", + client_ip: "10.0.0.1", + protocol: "udp", + ...rest, + } as ModelQueryLog; +}; + +describe("logIdentity", () => { + test("prefers the server id when present", () => { + expect(logIdentity(log({ id: "abc" }))).toBe("abc"); + }); + + test("composite fallback discriminates same-second bursts by query type", () => { + const a = log(); + const aaaa = { ...log(), dns_request: { domain: "example.com", query_type: "AAAA" } }; + expect(logIdentity(a)).not.toBe(logIdentity(aaaa)); + }); + + test("identical entries share an identity", () => { + expect(logIdentity(log())).toBe(logIdentity(log())); + }); +}); + +describe("computeNewQueryLogs", () => { + test("returns the prefix above the first overlapping entry", () => { + const current = [log({ domain: "c1.test" }), log({ domain: "c2.test" })]; + const fetched = [log({ domain: "n1.test" }), log({ domain: "n2.test" }), ...current]; + const diff = computeNewQueryLogs(fetched, current); + expect(diff.newLogs.map(l => l.dns_request?.domain)).toEqual(["n1.test", "n2.test"]); + expect(diff.overlapFound).toBe(true); + }); + + test("matches on server ids when available", () => { + const current = [log({ id: "x1" }), log({ id: "x2" })]; + const fetched = [log({ id: "x9", domain: "new.test" }), ...current]; + const diff = computeNewQueryLogs(fetched, current); + expect(diff.newLogs).toHaveLength(1); + expect(diff.overlapFound).toBe(true); + }); + + test("no new entries when the head is unchanged", () => { + const current = [log({ domain: "c1.test" }), log({ domain: "c2.test" })]; + const diff = computeNewQueryLogs([...current], current); + expect(diff.newLogs).toHaveLength(0); + expect(diff.overlapFound).toBe(true); + }); + + test("reports no overlap when the lists are disjoint", () => { + const current = [log({ domain: "old.test" })]; + const fetched = [log({ domain: "n1.test" }), log({ domain: "n2.test" })]; + const diff = computeNewQueryLogs(fetched, current); + expect(diff.newLogs).toHaveLength(2); + expect(diff.overlapFound).toBe(false); + }); + + test("empty current list: everything is new, no overlap", () => { + const diff = computeNewQueryLogs([log()], []); + expect(diff.newLogs).toHaveLength(1); + expect(diff.overlapFound).toBe(false); + }); + + test("empty fetch: nothing new, no overlap", () => { + const diff = computeNewQueryLogs([], [log()]); + expect(diff.newLogs).toHaveLength(0); + expect(diff.overlapFound).toBe(false); + }); + + test("same-second burst entries only match their exact counterpart", () => { + // A + AAAA at the same timestamp: fetching one more AAAA for a new domain must + // not be swallowed by the timestamp-equal A entry. + const a = log({ domain: "dup.test" }); + const current = [a]; + const aaaa = { ...log({ domain: "dup.test" }), dns_request: { domain: "dup.test", query_type: "AAAA" } }; + const diff = computeNewQueryLogs([aaaa, a], current); + expect(diff.newLogs).toHaveLength(1); + expect(diff.overlapFound).toBe(true); + }); +}); diff --git a/app/src/lib/consolidateLogs.ts b/app/src/lib/consolidateLogs.ts index 38bbff1d..49ec0b5a 100644 Binary files a/app/src/lib/consolidateLogs.ts and b/app/src/lib/consolidateLogs.ts differ diff --git a/app/src/lib/consts.ts b/app/src/lib/consts.ts index b2288028..076c2eb6 100644 --- a/app/src/lib/consts.ts +++ b/app/src/lib/consts.ts @@ -1,2 +1,25 @@ export const AUTH_KEY = "isAuthenticated"; -export const PASSWORD_COMPLEXITY_RULES = "Password must be 12-64 characters, contain at least one uppercase letter, one lowercase letter, one number, and one special character." \ No newline at end of file +export const PASSWORD_COMPLEXITY_RULES = "Password must be 12-64 characters, contain at least one uppercase letter, one lowercase letter, one number, and one special character." + +// Query-logs auto-refresh intervals (Grafana-style split refresh button). +// "auto" is the default cadence; ms: null = auto-refresh off. +export type RefreshIntervalKey = "off" | "auto" | "5s" | "10s" | "15s" | "30s" | "60s"; +export interface RefreshIntervalOption { + key: RefreshIntervalKey; + /** Menu entry label. */ + label: string; + /** Compact label shown on the split button while active. */ + buttonLabel: string; + ms: number | null; +} +export const QUERY_LOGS_REFRESH_INTERVALS: RefreshIntervalOption[] = [ + { key: "off", label: "Off", buttonLabel: "", ms: null }, + { key: "auto", label: "Auto (10s)", buttonLabel: "Auto", ms: 10_000 }, + { key: "5s", label: "5s", buttonLabel: "5s", ms: 5_000 }, + { key: "10s", label: "10s", buttonLabel: "10s", ms: 10_000 }, + { key: "15s", label: "15s", buttonLabel: "15s", ms: 15_000 }, + { key: "30s", label: "30s", buttonLabel: "30s", ms: 30_000 }, + { key: "60s", label: "60s", buttonLabel: "60s", ms: 60_000 }, +]; +export const refreshIntervalMsFor = (key: RefreshIntervalKey): number | null => + QUERY_LOGS_REFRESH_INTERVALS.find(option => option.key === key)?.ms ?? null; \ No newline at end of file diff --git a/app/src/lib/queryLogsDiff.ts b/app/src/lib/queryLogsDiff.ts new file mode 100644 index 00000000..a2bac9db --- /dev/null +++ b/app/src/lib/queryLogsDiff.ts @@ -0,0 +1,55 @@ +// queryLogsDiff — diff a freshly fetched page 1 against the displayed logs list. +// +// Used by the auto-refresh background tick: instead of replacing the list wholesale +// (which reset scroll and collapsed expanded cards), the tick computes which fetched +// entries are genuinely new and stages them behind a "N new queries" pill. + +import type { ModelQueryLog } from "@/api/client"; + +// How many entries from the head of the displayed list to index when looking for the +// overlap point. Ticks fetch 100 rows, so the overlap — if any — sits within the first +// 100 displayed entries; 150 leaves slack for pill merges between ticks. +const OVERLAP_WINDOW = 150; + +export interface QueryLogsDiff { + /** Entries in `fetched` newer than the displayed head, in fetched (newest-first) order. */ + newLogs: ModelQueryLog[]; + /** False when `fetched` shares no entry with the displayed head — the lists don't touch. */ + overlapFound: boolean; +} + +// Identity of one log entry for diffing. Prefers the server id; the fallback composite +// includes the timestamp AND the consolidation-signature fields plus query_type, because +// timestamps alone cannot discriminate — DNS bursts (A + AAAA) land in the same second. +export const logIdentity = (log: ModelQueryLog): string => + log.id || + [ + log.timestamp ?? "", + log.dns_request?.domain ?? "", + log.dns_request?.query_type ?? "", + log.status ?? "", + log.device_id ?? "", + log.client_ip ?? "", + log.protocol ?? "", + ].join("|"); + +/** + * Walk `fetched` (newest first) until the first entry already present near the head of + * `current`; the prefix before that point is new. No overlap means `fetched` is entirely + * unseen — at a full page size that implies a gap, which the caller must handle by + * replacing instead of prepending. Pure, O(n). + */ +export function computeNewQueryLogs( + fetched: ModelQueryLog[], + current: ModelQueryLog[] +): QueryLogsDiff { + const known = new Set(current.slice(0, OVERLAP_WINDOW).map(logIdentity)); + const newLogs: ModelQueryLog[] = []; + for (const log of fetched) { + if (known.has(logIdentity(log))) { + return { newLogs, overlapFound: true }; + } + newLogs.push(log); + } + return { newLogs, overlapFound: false }; +} diff --git a/app/src/pages/logs/Filters.tsx b/app/src/pages/logs/Filters.tsx index 85e5d5e4..34970c78 100644 --- a/app/src/pages/logs/Filters.tsx +++ b/app/src/pages/logs/Filters.tsx @@ -1,7 +1,18 @@ import { type JSX } from "react"; -import { Search, ListFilter, ArrowDownAZ, RefreshCw, Monitor, Clock } from "lucide-react"; +import { Search, ListFilter, ArrowDownAZ, RefreshCw, Monitor, Clock, ChevronDown } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + QUERY_LOGS_REFRESH_INTERVALS, + type RefreshIntervalKey, +} from "@/lib/consts"; import { Select, SelectContent, @@ -21,13 +32,94 @@ interface FiltersProps { onRefresh: () => void; timespanValue: string | undefined; onTimespanChange: (value: string | undefined) => void; - isAutoRefreshing?: boolean; - onToggleAutoRefresh?: () => void; + /** Selected auto-refresh cadence ("off" = disabled). */ + refreshIntervalKey: RefreshIntervalKey; + onRefreshIntervalChange: (key: RefreshIntervalKey) => void; + /** True while a manual (one-shot) refresh is in flight — spins the refresh icon. */ + isRefreshing?: boolean; deviceIdValue: string | undefined; onDeviceIdChange: (value: string | undefined) => void; availableDeviceIds: string[]; } +// 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 +// on the button and the refresh icon spins continuously (the "live" cue). +const RefreshControls = ({ + onRefresh, + isRefreshing, + refreshIntervalKey, + onRefreshIntervalChange, +}: { + onRefresh: () => void; + isRefreshing: boolean; + refreshIntervalKey: RefreshIntervalKey; + onRefreshIntervalChange: (key: RefreshIntervalKey) => void; +}): JSX.Element => { + const activeOption = QUERY_LOGS_REFRESH_INTERVALS.find(option => option.key === refreshIntervalKey); + const isAutoRefreshing = (activeOption?.ms ?? null) !== null; + return ( +
+ + + + + + {/* align="start": open down-right so the menu doesn't drop over the + quick-rule column at the right edge of the cards below. Radix + collision handling still flips it where the viewport is too narrow. */} + + onRefreshIntervalChange(value as RefreshIntervalKey)} + > + {QUERY_LOGS_REFRESH_INTERVALS.map(option => ( + + {option.label} + + ))} + + + +
+ ); +}; + const Filters = ({ searchInputValue, onSearchInputChange, @@ -39,8 +131,9 @@ const Filters = ({ onRefresh, timespanValue, onTimespanChange, - isAutoRefreshing = false, - onToggleAutoRefresh, + refreshIntervalKey, + onRefreshIntervalChange, + isRefreshing = false, deviceIdValue, onDeviceIdChange, availableDeviceIds, @@ -65,15 +158,12 @@ const Filters = ({ onBlur={() => { if (window.innerWidth < 1024) onSearchCommit(); }} />
- + {/* Row 2 (mobile: single horizontal scroll line) / Full single row (desktop). @@ -169,17 +259,14 @@ const Filters = ({ - {/* Desktop refresh button (hidden on mobile second row) */} -
- + {/* Desktop refresh controls (hidden on mobile second row) */} +
+
diff --git a/app/src/pages/logs/Logs.tsx b/app/src/pages/logs/Logs.tsx index 86a8ac0e..8c7b9c55 100644 --- a/app/src/pages/logs/Logs.tsx +++ b/app/src/pages/logs/Logs.tsx @@ -12,10 +12,12 @@ import LogsNotActive from "./LogsNotActive"; import QueryLogCard from "./QueryLogCard"; import QuickRuleSheet, { type QuickRuleAction } from "./QuickRuleSheet"; import { consolidateLogs, toSingletonGroup } from "@/lib/consolidateLogs"; +import { computeNewQueryLogs } from "@/lib/queryLogsDiff"; +import { refreshIntervalMsFor, type RefreshIntervalKey } from "@/lib/consts"; import api from "@/api/api"; import { useAppStore } from "@/store/general"; import { Skeleton } from "@/components/ui/skeleton"; -import { Info, X } from "lucide-react"; +import { ArrowUp, Info, X } from "lucide-react"; import { useScreenDetector } from "@/hooks/useScreenDetector"; import { useSubscriptionGuard } from "@/hooks/useSubscriptionGuard"; import LimitedAccessBanner from "@/components/LimitedAccessBanner"; @@ -35,7 +37,11 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [isAutoRefreshing, setIsAutoRefreshing] = useState(false); + // Auto-refresh cadence, selected via the split refresh button's interval menu + // ("off" = disabled). Session-only by design — never persisted. + const [refreshIntervalKey, setRefreshIntervalKey] = useState("off"); + const refreshIntervalMs = refreshIntervalMsFor(refreshIntervalKey); + const isAutoRefreshing = refreshIntervalMs !== null; const [refreshTrigger, setRefreshTrigger] = useState(0); // Add trigger for forced refresh // Fade choreography for page-1 loads: true = list held at opacity-0. Starts true so the // initial load fades in. Set true by every refresh/filter trigger; cleared ONLY by the @@ -46,6 +52,22 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { const [quickRuleDomain, setQuickRuleDomain] = useState(undefined); const [quickRuleDefaultAction, setQuickRuleDefaultAction] = useState("denylist"); + // New entries found by the auto-refresh background tick, staged behind the + // "N new queries" pill instead of disturbing the list. Recomputed wholesale + // against the displayed list on every tick. pendingOverflow: the tick's full + // page shared nothing with the list — prepending would leave a gap. + const [pendingLogs, setPendingLogs] = useState([]); + const [pendingOverflow, setPendingOverflow] = useState(false); + + // Expansion state of cards, lifted here (keyed by group identity, not React key) + // so open cards survive the remounts caused by refreshes and pill merges. + const [expandedKeys, setExpandedKeys] = useState>(new Set()); + + // Group identities revealed by the last pill click. A prepend remounts EVERY card + // (React keys embed the list index), so the entry animation must be scoped to the + // groups that are actually new — not everything that remounted. + const [freshIdentities, setFreshIdentities] = useState>(new Set()); + // Search input (uncommitted while typing) and committed value that triggers requests const [searchInputValue, setSearchInputValue] = useState(""); const [committedSearchValue, setCommittedSearchValue] = useState(""); @@ -82,6 +104,10 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { const observer = useRef(null); const previousProfileIdRef = useRef(undefined); + // Mirror of `logs` for reads inside the background tick, which runs outside the + // render cycle (setInterval) and must diff against the list as displayed NOW. + const logsRef = useRef([]); + const bgFetchInFlight = useRef(false); const lastLogRef = useCallback( (node: HTMLDivElement | null) => { if (loading) return; @@ -172,13 +198,21 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { } }, []); - // Reset logs, device IDs and page when committed filters change + useEffect(() => { + logsRef.current = logs; + }, [logs]); + + // Reset logs, device IDs, staged entries and page when committed filters change useEffect(() => { setLogs([]); setPage(1); setHasMore(true); setAllAvailableDeviceIds([]); setIsListFading(true); + setPendingLogs([]); + setPendingOverflow(false); + setExpandedKeys(new Set()); + setFreshIdentities(new Set()); }, [committedSearchValue, filterValue, sortValue, timespanValue, deviceIdValue]); // Fade-in: once no fetch is in flight, release the fade after a short delay so the @@ -196,6 +230,10 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { if (previousProfileIdRef.current && previousProfileIdRef.current !== currentId) { setIsQuickRuleSheetOpen(false); setQuickRuleDomain(undefined); + setPendingLogs([]); + setPendingOverflow(false); + setExpandedKeys(new Set()); + setFreshIdentities(new Set()); } previousProfileIdRef.current = currentId; }, [activeProfile?.profile_id]); @@ -204,6 +242,15 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { setCommittedSearchValue(prev => prev === searchInputValue ? prev : searchInputValue); }, [searchInputValue]); + const toggleCardExpanded = useCallback((identity: string) => { + setExpandedKeys(prev => { + const next = new Set(prev); + if (next.has(identity)) next.delete(identity); + else next.add(identity); + return next; + }); + }, []); + // Fetch logs and then fetch logos for the batch useEffect(() => { let cancelled = false; @@ -220,7 +267,7 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { try { // Status is already handled in filters.Status // Use expanded limit on first page to gather more device IDs; subsequent pages respect configured limit - const effectiveLimit = (page === 1 && !isAutoRefreshing) ? 100 : filters.Limit; + const effectiveLimit = page === 1 ? 100 : filters.Limit; const searchParam = committedSearchValue || undefined; const response = await api.Client.queryLogsApi.apiV1ProfilesIdLogsGet( activeProfile.profile_id, @@ -286,48 +333,137 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- committedSearchValue, isAutoRefreshing, and sortValue are consumed via the `filters` object and `refreshTrigger`; adding them directly would cause redundant re-fetches since the filters object already captures their derived values + // eslint-disable-next-line react-hooks/exhaustive-deps -- committedSearchValue and sortValue are consumed via the `filters` object and `refreshTrigger`; adding them directly would cause redundant re-fetches since the filters object already captures their derived values }, [page, filters.Limit, filters.Status, filters.Timespan.Value, filters.Search, filters.Sort, activeProfile, refreshTrigger, deviceIdValue]); - // Auto-refresh effect - useEffect(() => { - let interval: NodeJS.Timeout | null = null; - - if (isAutoRefreshing && activeProfile?.profile_id) { - interval = setInterval(() => { - // Force refresh by incrementing trigger and resetting to first page. - // The current list stays on screen until the page-1 response replaces it - // wholesale — clearing it here would blank (or, with the old fade logic, - // permanently hide) the cards on every tick. - setPage(1); - setIsListFading(true); - setRefreshTrigger(prev => prev + 1); - }, 10000); // 10 seconds + // Spin the refresh icon for at least a half rotation (500ms) per manual refresh — + // tied to `loading` alone, a fast response ends the spin after a couple of frames + // and the click appears to do nothing. + const [manualSpinActive, setManualSpinActive] = useState(false); + const manualSpinTimer = useRef | null>(null); + useEffect(() => () => { + if (manualSpinTimer.current) clearTimeout(manualSpinTimer.current); + }, []); + + // Handle manual (one-shot) refresh: page-1 replace, discarding staged entries. + const handleRefresh = () => { + setPage(1); + setIsListFading(true); + setPendingLogs([]); + setPendingOverflow(false); + setFreshIdentities(new Set()); + setRefreshTrigger(prev => prev + 1); + setManualSpinActive(true); + if (manualSpinTimer.current) clearTimeout(manualSpinTimer.current); + manualSpinTimer.current = setTimeout(() => setManualSpinActive(false), 500); + }; + + const logsEnabled = + activeProfile?.settings?.logs.enabled !== false; // default to true if undefined + + // Auto-refresh background tick: fetch page 1 and diff it against the displayed + // list; genuinely new entries wait behind the "N new queries" pill instead of + // replacing the list (which reset scroll position and collapsed open cards). + const runBackgroundTick = async () => { + const profileId = activeProfile?.profile_id; + if (document.hidden || !profileId || !logsEnabled) return; + if (loading || bgFetchInFlight.current) return; + if (sortValue !== "created") { + // Non-temporal sorts have no meaningful prepend point — fall back to the + // wholesale page-1 replace. + handleRefresh(); + return; } + bgFetchInFlight.current = true; + try { + const response = await api.Client.queryLogsApi.apiV1ProfilesIdLogsGet( + profileId, + 1, + 100, + filters.Status, + filters.Timespan.Value, + deviceIdValue || undefined, + committedSearchValue || undefined, + sortValue + ); + if (response.status !== 200) return; + const fetched = response.data || []; + if (logsRef.current.length === 0) { + // Nothing on screen to preserve — apply directly, a pill over an + // empty state helps no one. + setLogs(fetched); + setHasMore(fetched.length === 100); + setPendingLogs([]); + setPendingOverflow(false); + return; + } + const { newLogs, overlapFound } = computeNewQueryLogs(fetched, logsRef.current); + setPendingLogs(newLogs); + setPendingOverflow(!overlapFound && fetched.length === 100); + } catch { + // Background ticks fail silently — the next tick retries; foreground + // fetches own user-visible error reporting. + } finally { + bgFetchInFlight.current = false; + } + }; + // Latest-closure ref so the interval (bound once per auto-refresh session) always + // calls a tick that sees current filters/logs without restarting the timer. + const tickRef = useRef(runBackgroundTick); + useEffect(() => { + tickRef.current = runBackgroundTick; + }); + // Auto-refresh loop at the selected cadence: paused while the tab is hidden (the + // tick self-skips), with an immediate catch-up tick on return to a visible tab. + useEffect(() => { + if (refreshIntervalMs === null || !activeProfile?.profile_id) return; + const interval = setInterval(() => { + void tickRef.current(); + }, refreshIntervalMs); + const onVisibilityChange = () => { + if (!document.hidden) void tickRef.current(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); return () => { - if (interval) { - clearInterval(interval); - } + clearInterval(interval); + document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, [isAutoRefreshing, activeProfile?.profile_id]); - - // Handle auto-refresh toggle - const handleToggleAutoRefresh = () => { - setIsAutoRefreshing(prev => !prev); - if (!isAutoRefreshing) { - // When starting auto-refresh, immediately refresh once - setPage(1); - setIsListFading(true); - setRefreshTrigger(prev => prev + 1); + }, [refreshIntervalMs, activeProfile?.profile_id]); + + // Interval menu selection + const handleRefreshIntervalChange = (key: RefreshIntervalKey) => { + const wasOn = isAutoRefreshing; + setRefreshIntervalKey(key); + if (refreshIntervalMsFor(key) === null) { + setPendingLogs([]); + setPendingOverflow(false); + } else if (!wasOn) { + // Immediate feedback without disturbing the current list; interval-to- + // interval changes just retime the loop. + void tickRef.current(); } }; - // Handle manual refresh - const handleRefresh = () => { - setPage(1); - setIsListFading(true); - setRefreshTrigger(prev => prev + 1); + // Reveal staged entries: prepend them above the current list. When the tick found + // a full page with no overlap, prepending would leave a gap — reload instead. + const handleShowPending = () => { + if (pendingOverflow) { + handleRefresh(); + return; + } + const previous = logsRef.current; + const merged = [...pendingLogs, ...previous]; + const previousIdentities = new Set(consolidateLogs(previous).map(group => group.identity)); + setFreshIdentities( + new Set( + consolidateLogs(merged) + .map(group => group.identity) + .filter(identity => !previousIdentities.has(identity)) + ) + ); + setLogs(merged); + setPendingLogs([]); }; // --- Pull-to-refresh (mobile only) --- @@ -368,10 +504,8 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { if (pullDistance > PULL_THRESHOLD && !isRefreshing && !loading) { setIsRefreshing(true); setPullDistance(0); - // Trigger the existing refresh mechanism (keeps current rows until new data lands) - setPage(1); - setIsListFading(true); - setRefreshTrigger(prev => prev + 1); + // Same one-shot path as the refresh button (also clears staged pill entries) + handleRefresh(); // Reset refreshing indicator after a short delay setTimeout(() => setIsRefreshing(false), 1200); } else { @@ -379,9 +513,6 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { } }, [pullDistance, isRefreshing, loading, isMobile]); - const logsEnabled = - activeProfile?.settings?.logs.enabled !== false; // default to true if undefined - return (
@@ -408,13 +539,34 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { onRefresh={handleRefresh} timespanValue={timespanValue} onTimespanChange={setTimespanValue} - isAutoRefreshing={isAutoRefreshing} - onToggleAutoRefresh={handleToggleAutoRefresh} + refreshIntervalKey={refreshIntervalKey} + onRefreshIntervalChange={handleRefreshIntervalChange} + isRefreshing={manualSpinActive || (loading && page === 1)} deviceIdValue={deviceIdValue} onDeviceIdChange={setDeviceIdValue} availableDeviceIds={allAvailableDeviceIds} /> + {/* 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) + entirely while nothing is staged; the wrapper stays mounted so the live + region exists before the pill text arrives. */} +
+ {pendingLogs.length > 0 && ( + + )} +
+
{!logsEnabled && ( @@ -489,6 +641,9 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => { blocklistNames={blocklistNames} serviceNames={serviceNames} onExpand={dismissExpandHint} + expanded={expandedKeys.has(group.identity)} + onToggleExpanded={() => toggleCardExpanded(group.identity)} + animateEntry={freshIdentities.has(group.identity)} /> ); })} diff --git a/app/src/pages/logs/QueryLogCard.tsx b/app/src/pages/logs/QueryLogCard.tsx index c61b0305..97fc5b02 100644 --- a/app/src/pages/logs/QueryLogCard.tsx +++ b/app/src/pages/logs/QueryLogCard.tsx @@ -28,9 +28,18 @@ interface QueryLogCardProps { serviceNames?: Record; /** Called the first time this row is expanded (used to dismiss the one-time mobile hint). */ onExpand?: () => void; + /** + * Controlled expansion: when defined, the card renders this state and reports toggles + * via `onToggleExpanded` instead of holding its own — lets the owner keep cards open + * across list updates that remount them. Undefined → uncontrolled (internal state). + */ + expanded?: boolean; + onToggleExpanded?: () => void; + /** Play the entry animation on mount (used for entries revealed by the new-queries pill). */ + animateEntry?: boolean; } -const QueryLogCard = ({ log, group, isLast, lastLogRef, onQuickRule, quickRuleRestricted, blocklistNames, serviceNames, onExpand }: QueryLogCardProps): JSX.Element | null => { +const QueryLogCard = ({ log, group, isLast, lastLogRef, onQuickRule, quickRuleRestricted, blocklistNames, serviceNames, onExpand, expanded: expandedProp, onToggleExpanded, animateEntry }: QueryLogCardProps): JSX.Element | null => { // Consolidation: count>1 means this card stands in for a run of adjacent duplicate queries. const count = group?.count ?? 1; const isConsolidated = count > 1; @@ -99,13 +108,15 @@ const QueryLogCard = ({ log, group, isLast, lastLogRef, onQuickRule, quickRuleRe ? group!.members.flatMap((m) => m.reasons ?? []) : (log.reasons ?? []); const hasReasons = reasons.length > 0; - const [expanded, setExpanded] = useState(false); + const [internalExpanded, setInternalExpanded] = useState(false); + const isControlled = expandedProp !== undefined; + const expanded = isControlled ? expandedProp : internalExpanded; const panelId = useId(); - const toggleExpanded = () => setExpanded(v => { - const next = !v; - if (next) onExpand?.(); - return next; - }); + const toggleExpanded = () => { + if (!expanded) onExpand?.(); + if (isControlled) onToggleExpanded?.(); + else setInternalExpanded(v => !v); + }; // Device ID: backend allows up to 36 chars; truncate only for mobile (<=768px) const { isMobile } = useScreenDetector(); @@ -251,7 +262,8 @@ const QueryLogCard = ({ log, group, isLast, lastLogRef, onQuickRule, quickRuleRe // intensity per theme) — the only boundary feedback touch users ever get. expanded && "border-[var(--tailwind-colors-rdns-600)] dark:border-[var(--tailwind-colors-rdns-600)]/40", // Press/active feedback (works on touch where there is no hover) — subtle tint on tap. - "active:bg-[var(--shadcn-ui-app-accent)] dark:active:bg-[var(--shadcn-ui-app-accent)]" + "active:bg-[var(--shadcn-ui-app-accent)] dark:active:bg-[var(--shadcn-ui-app-accent)]", + animateEntry && "animate-in fade-in slide-in-from-top-2 duration-500 ease-out motion-reduce:animate-none" )} > {/* Whole-card expand/collapse trigger: a real button (native keyboard/focus/aria).