From 39276f0f999942862d520dbd0459c853f9fc8606 Mon Sep 17 00:00:00 2001 From: Maciek Date: Tue, 14 Jul 2026 13:09:57 +0200 Subject: [PATCH 1/3] feat(app): Query Logs visual deduplication Signed-off-by: Maciek --- .../e2e/logs/logs-mobile-overflow.spec.ts | 79 ++++++++++++ app/src/__tests__/unit/QueryLogCard.test.tsx | 56 +++++++++ app/src/__tests__/unit/QueryLogs.test.tsx | 33 +++++- .../unit/lib/consolidateLogs.test.ts | 112 ++++++++++++++++++ app/src/lib/consolidateLogs.ts | Bin 0 -> 4958 bytes app/src/pages/logs/Logs.tsx | 21 +++- app/src/pages/logs/QueryLogCard.tsx | 68 +++++++++-- 7 files changed, 353 insertions(+), 16 deletions(-) create mode 100644 app/src/__tests__/unit/lib/consolidateLogs.test.ts create mode 100644 app/src/lib/consolidateLogs.ts diff --git a/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts b/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts index 9d072ac1..cbff1c2e 100644 --- a/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts +++ b/app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts @@ -255,4 +255,83 @@ test.describe('Logs mobile layout', () => { await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 }); await expect(page.getByTestId('logs-expand-hint')).toHaveCount(0); }); + + test('consolidation: adjacent duplicate queries collapse into one card with a ×N badge', async ({ page }) => { + await registerMocks(page, { + authenticated: true, + customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }] + }); + + // Two adjacent rows share domain/status/device/client_ip/protocol and differ only in + // query_type (A + AAAA) → they consolidate. A third distinct row stays separate. + const now = new Date().toISOString(); + const items = [ + { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'dup.example.test', query_type: 'A', response_code: 'NOERROR' } }, + { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'dup.example.test', query_type: 'AAAA', response_code: 'NOERROR' } }, + { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'd1', client_ip: '10.0.0.1', dns_request: { domain: 'other.example.test', query_type: 'A', response_code: 'NOERROR' } } + ]; + await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); + }); + + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 }); + + // 3 raw logs → 2 cards (the A+AAAA pair merges). The badge renders once per layout + // branch (desktop + mobile, one CSS-hidden), so assert on the single VISIBLE badge. + await expect(page.getByTestId('querylog-card-toggle')).toHaveCount(2); + const badge = page.getByTestId('querylog-count-badge').and(page.locator(':visible')); + await expect(badge).toHaveCount(1); + await expect(badge).toHaveText('×2'); + + // Expanding the merged card surfaces the aggregated occurrence count and query types. + await page.getByTestId('querylog-card-toggle').first().click(); + const panel = page.getByTestId('querylog-expanded-panel').first(); + await expect(panel).toHaveAttribute('data-expanded', 'true'); + await expect(panel.getByTestId('querylog-detail-occurrences')).toHaveText('2'); + await expect(panel.getByTestId('querylog-detail-query-type')).toHaveText('A, AAAA'); + }); + + test('tablet width: meta labels stack vertically and the row has no horizontal overflow', async ({ page }, testInfo) => { + // The tablet band (769–1023px) renders the desktop branch at Tailwind `md`. No project sits + // there, so drive it on the desktop project with an explicit tablet viewport. + test.skip(!/chromium-desktop/i.test(testInfo.project.name), 'tablet-band layout is desktop-branch only'); + await page.setViewportSize({ width: 820, height: 1000 }); + + await registerMocks(page, { + authenticated: true, + customProfiles: [{ id: 'prof1', profile_id: 'prof1', name: 'Default', settings: { logs: { enabled: true } } }] + }); + const now = new Date().toISOString(); + const items = [ + // Long domain + blocked (so DNSSEC/Blocked labels are present in the stack). + { profile_id: 'prof1', timestamp: now, status: 'blocked', protocol: 'dns', device_id: 'device-tablet', client_ip: '10.0.0.1', dns_request: { domain: 'a-very-long-subdomain-name.example-reallylongdomainforlayout-validation.test', query_type: 'A', response_code: 'NOERROR', dnssec: true } }, + { profile_id: 'prof1', timestamp: now, status: 'processed', protocol: 'dns', device_id: 'device-tablet', client_ip: '10.0.0.2', dns_request: { domain: 'short.example.test', query_type: 'A', response_code: 'NOERROR' } } + ]; + await page.route(/\/api\/v1\/profiles\/prof1\/logs/i, route => { + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(items) }); + }); + + await page.goto('/query-logs'); + await page.getByTestId('logs-scroll-container').first().waitFor({ state: 'attached', timeout: 10000 }); + + // The meta-label group (protocol/DNSSEC/Blocked) must be a vertical stack at tablet width. + const flexDir = await page.evaluate(() => { + const group = document.querySelector('.md\\:flex.flex-col.lg\\:flex-row') as HTMLElement | null; + return group ? getComputedStyle(group).flexDirection : 'not-found'; + }); + expect(flexDir).toBe('column'); + + // No horizontal overflow at tablet width even with the long domain. + const result = await page.evaluate(() => { + const docEl = document.documentElement; + const vw = window.innerWidth; + const scrollingElWidth = document.scrollingElement ? document.scrollingElement.scrollWidth : docEl.scrollWidth; + const sc = document.querySelector('[data-testid="logs-scroll-container"]') as HTMLElement | null; + const scOverflow = sc ? sc.scrollWidth - sc.clientWidth : 0; + return { vw, scrollingElWidth, scOverflow }; + }); + expect(result.scrollingElWidth).toBeLessThanOrEqual(result.vw + 1); + expect(result.scOverflow).toBeLessThanOrEqual(1); + }); }); diff --git a/app/src/__tests__/unit/QueryLogCard.test.tsx b/app/src/__tests__/unit/QueryLogCard.test.tsx index 1849a78d..9efd71be 100644 --- a/app/src/__tests__/unit/QueryLogCard.test.tsx +++ b/app/src/__tests__/unit/QueryLogCard.test.tsx @@ -238,6 +238,62 @@ describe('QueryLogCard whole-card expansion', () => { }); }); +describe('QueryLogCard consolidation (issue #161)', () => { + beforeEach(() => { + (window as unknown as { innerWidth: number }).innerWidth = 1440; + stubDesktopMatchMedia(true); + }); + + const memberA: ModelQueryLog = { + profile_id: 'p-con', + timestamp: '2026-06-15T10:20:32.000Z', + status: 'processed', + protocol: 'dns', + device_id: 'con-device', + client_ip: '10.0.0.9', + dns_request: { domain: 'dup.example.com', query_type: 'A', response_code: 'NOERROR' }, + }; + const memberAAAA: ModelQueryLog = { + ...memberA, + timestamp: '2026-06-15T10:20:30.000Z', + dns_request: { domain: 'dup.example.com', query_type: 'AAAA', response_code: 'NXDOMAIN' }, + }; + const group = { + key: 'con-group', + representative: memberA, + count: 3, + members: [memberA, memberAAAA, memberA], + firstTimestamp: memberA.timestamp, + lastTimestamp: memberAAAA.timestamp, + queryTypes: ['A', 'AAAA'], + responseCodes: ['NOERROR', 'NXDOMAIN'], + }; + + test('single-entry row (no group / count 1) shows no count badge', () => { + render(); + expect(screen.queryByTestId('querylog-count-badge')).not.toBeInTheDocument(); + render(); + expect(screen.queryByTestId('querylog-count-badge')).not.toBeInTheDocument(); + }); + + test('consolidated row shows a ×N count badge', () => { + render(); + const badge = screen.getByTestId('querylog-count-badge'); + expect(badge).toHaveTextContent('×3'); + expect(badge).toHaveAttribute('data-count', '3'); + }); + + test('expanded panel aggregates query types, response codes, occurrences and a time range', () => { + render(); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + expect(screen.getByTestId('querylog-detail-query-type')).toHaveTextContent('A, AAAA'); + expect(screen.getByTestId('querylog-detail-response-code')).toHaveTextContent('NOERROR, NXDOMAIN'); + expect(screen.getByTestId('querylog-detail-occurrences')).toHaveTextContent('3'); + // Time range renders both endpoints separated by an en dash. + expect(screen.getByTestId('querylog-detail-timestamp').textContent).toMatch(/–/); + }); +}); + describe('QueryLogCard quick rule button', () => { beforeEach(() => { (window as unknown as { innerWidth: number }).innerWidth = 1280; diff --git a/app/src/__tests__/unit/QueryLogs.test.tsx b/app/src/__tests__/unit/QueryLogs.test.tsx index 0866e9de..7bef149d 100644 --- a/app/src/__tests__/unit/QueryLogs.test.tsx +++ b/app/src/__tests__/unit/QueryLogs.test.tsx @@ -59,10 +59,11 @@ vi.mock("@/pages/logs/Filters", () => ({ onSearchInputChange, onSearchCommit, onFilterChange, + onSortChange, onTimespanChange, onDeviceIdChange, onRefresh, - }: { searchInputValue: string; onSearchInputChange?: (v: string) => void; onSearchCommit?: () => void; onFilterChange?: (v: string) => void; onTimespanChange?: (v: string) => void; onDeviceIdChange?: (v: string) => void; onRefresh?: () => void }) => ( + }: { 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 }) => (
({ /> + @@ -231,6 +233,35 @@ describe("QueryLogs", () => { ); }); + test("consolidates adjacent duplicate rows into a single card under the default time sort", async () => { + // Same domain/status/device/client_ip/protocol, differing only in query_type (A + AAAA): + // these are sequential duplicates and collapse into one card. + const dupA = makeLog({ dns_request: { domain: "dup.example.com", query_type: "A" }, timestamp: "2024-01-01T00:00:02Z" }); + const dupAAAA = makeLog({ dns_request: { domain: "dup.example.com", query_type: "AAAA" }, timestamp: "2024-01-01T00:00:01Z" }); + const other = makeLog({ dns_request: { domain: "other.example.com", query_type: "A" }, timestamp: "2024-01-01T00:00:00Z" }); + queryLogsMock.mockResolvedValue({ status: 200, data: [dupA, dupAAAA, other] }); + + render(); + // 3 raw logs → 2 cards (the A+AAAA pair merges). + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(2)); + }); + + test("does not consolidate when sorted by domain", async () => { + const dupA = makeLog({ dns_request: { domain: "dup.example.com", query_type: "A" }, timestamp: "2024-01-01T00:00:02Z" }); + const dupAAAA = makeLog({ dns_request: { domain: "dup.example.com", query_type: "AAAA" }, timestamp: "2024-01-01T00:00:01Z" }); + queryLogsMock.mockResolvedValue({ status: 200, data: [dupA, dupAAAA] }); + + render(); + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(1)); + + act(() => { + fireEvent.click(screen.getByTestId("sort-domain")); + }); + + // Under domain sort, sequential-duplicate consolidation is disabled → both rows render. + await waitFor(() => expect(screen.getAllByTestId("log-card")).toHaveLength(2)); + }); + 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 new file mode 100644 index 00000000..ba7ebee2 --- /dev/null +++ b/app/src/__tests__/unit/lib/consolidateLogs.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { consolidateLogs, toSingletonGroup } from '@/lib/consolidateLogs'; +import type { ModelQueryLog } from '@/api/client'; + +// Minimal log factory — override only what a test cares about. +const log = (over: Partial & { domain?: string; query_type?: string; response_code?: string }): ModelQueryLog => { + const { domain, query_type, response_code, ...rest } = over; + return { + profile_id: 'p1', + status: 'processed', + protocol: 'dns', + device_id: 'dev1', + client_ip: '10.0.0.1', + timestamp: '2026-06-15T10:00:00.000Z', + dns_request: { domain, query_type, response_code }, + ...rest, + }; +}; + +describe('consolidateLogs', () => { + it('merges an adjacent A + AAAA run for the same domain into one group', () => { + const groups = consolidateLogs([ + log({ domain: 'example.com', query_type: 'A', response_code: 'NOERROR', timestamp: '2026-06-15T10:00:02.000Z' }), + log({ domain: 'example.com', query_type: 'AAAA', response_code: 'NOERROR', timestamp: '2026-06-15T10:00:01.000Z' }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].count).toBe(2); + expect(groups[0].queryTypes).toEqual(['A', 'AAAA']); + expect(groups[0].responseCodes).toEqual(['NOERROR']); + expect(groups[0].representative.dns_request?.query_type).toBe('A'); + expect(groups[0].firstTimestamp).toBe('2026-06-15T10:00:02.000Z'); + expect(groups[0].lastTimestamp).toBe('2026-06-15T10:00:01.000Z'); + }); + + it('keeps non-adjacent same-domain entries separate (X, Y, X -> 3 groups)', () => { + const groups = consolidateLogs([ + log({ domain: 'x.com' }), + log({ domain: 'y.com' }), + log({ domain: 'x.com' }), + ]); + expect(groups.map((g) => g.representative.dns_request?.domain)).toEqual(['x.com', 'y.com', 'x.com']); + expect(groups.every((g) => g.count === 1)).toBe(true); + }); + + it('does not merge across a status boundary', () => { + const groups = consolidateLogs([ + log({ domain: 'ads.com', status: 'processed' }), + log({ domain: 'ads.com', status: 'blocked' }), + ]); + expect(groups).toHaveLength(2); + }); + + it('does not merge across differing device_id, client_ip, or protocol', () => { + expect(consolidateLogs([log({ domain: 'a.com', device_id: 'dev1' }), log({ domain: 'a.com', device_id: 'dev2' })])).toHaveLength(2); + expect(consolidateLogs([log({ domain: 'a.com', client_ip: '10.0.0.1' }), log({ domain: 'a.com', client_ip: '10.0.0.2' })])).toHaveLength(2); + expect(consolidateLogs([log({ domain: 'a.com', protocol: 'dns' }), log({ domain: 'a.com', protocol: 'doh' })])).toHaveLength(2); + }); + + it('merges an adjacent run of empty-domain rows but never empty with non-empty', () => { + const merged = consolidateLogs([ + log({ domain: undefined, query_type: 'A' }), + log({ domain: undefined, query_type: 'AAAA' }), + ]); + expect(merged).toHaveLength(1); + expect(merged[0].count).toBe(2); + + const split = consolidateLogs([ + log({ domain: undefined }), + log({ domain: 'real.com' }), + ]); + expect(split).toHaveLength(2); + }); + + it('normalizes case and a trailing dot when comparing domains', () => { + const groups = consolidateLogs([ + log({ domain: 'Example.com.', query_type: 'A' }), + log({ domain: 'example.com', query_type: 'AAAA' }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].count).toBe(2); + }); + + it('preserves order and assigns count 1 to singletons', () => { + const groups = consolidateLogs([ + log({ domain: 'a.com', query_type: 'A' }), + log({ domain: 'a.com', query_type: 'AAAA' }), + log({ domain: 'b.com' }), + ]); + expect(groups.map((g) => g.count)).toEqual([2, 1]); + expect(groups.map((g) => g.representative.dns_request?.domain)).toEqual(['a.com', 'b.com']); + }); + + it('produces distinct, stable keys for non-adjacent groups with the same signature', () => { + const groups = consolidateLogs([ + log({ domain: 'x.com' }), + log({ domain: 'y.com' }), + log({ domain: 'x.com' }), + ]); + expect(new Set(groups.map((g) => g.key)).size).toBe(3); + }); + + it('returns [] for an empty input', () => { + expect(consolidateLogs([])).toEqual([]); + }); + + it('toSingletonGroup wraps one log as a count-1 group', () => { + const g = toSingletonGroup(log({ domain: 'a.com', query_type: 'A' }), 0); + expect(g.count).toBe(1); + expect(g.queryTypes).toEqual(['A']); + expect(g.representative.dns_request?.domain).toBe('a.com'); + }); +}); diff --git a/app/src/lib/consolidateLogs.ts b/app/src/lib/consolidateLogs.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5883f7a6d12fee3ba18dadf84b43ae5d3a7e9f5 GIT binary patch literal 4958 zcmb_g+io1k5zX^{MR{Y`v(oOY1j9x^N>moKY$#DICsYsz3_&$#dUsl!nVxj_klZK) z@{|t<@`e7Aoa*kmt*Kmq5CqBXOWjV@scNp*l=`w`W;UWxp@$gn>0!zTkv_OZ`( zB~(q;i9Oq9Wg`ryMk3Q)N=k4yT2e*F7CV2nYa8G$aeh^}S>Dk*9>v29EEmPfmD zxfN~cSg2C~*=pmK_zBR;QW^lmYIF^`!coE?a^j}W-YwGEOjQjocqHiq{YK}q{4HlI z&d@z=jjm|^%e83KIz(-LJe$coe+PI=LRJ=@jK}DSqN}jRzH}tDa%mrAxoCXQ_@E5 z6z3pnLYiJlwERH&`Nbe?@GWKRkQ`Oxy^J0Pl*hjH!odR8z3nc&_;}pBAFHPq2O4yV zP`L-9*J~)nzgO`4TA|7#?=TZs4hHCg3v%T{fvAW4KA&&qOPYhHHuH-CaLGIyOZrn`^3QF@ z&8Y3sVHT2<`c|4uz@EoGSDS&-oDr(@Jf+=&OK|@34v3aA&2*K{JzdY`urz0Z!hlsn z_L2xWJ0gs9#tp~ycF5?&b4miE^vEwdhw1r=FP?aSUc8|Be33f+nk_$t<;fyN0hFlR zWc{b~(>0KpFHqO8=9O;+ZqKwUxTPIYPi+(03ygGINc5rCulYEt4MC}d%L;A@DDx_E zol_{9Ty3|YER>5!{gN|=xRV5QFBx5Y9_nCCd2OxCQhJBNaIJj@UMJ8dNOsG%dTv#_ z+P6(lc_I)!pQ|MkJD)}u0(V}OI=hy6N-wL%?N^bGV~n^pqk?xV6)hrl3AF#uKI2EeFy684!TUA2LmIUvEPFfN4N2miTA`poRGww+G|lwsKc zf&2vMa}PI@ZI`VvOZak%AYK~o$JV__gQCT7nSfZ3N{{Nicgc%HrzURGFsa*SWk*oCx|_0ARe{P^)AUS|0C za*`DE>=}Kz0KF7UF*uGf#xSRCT%r~W`YS|v0Pp=zBQZACgnK6^C-j6X{ml2!Eh*+1kvHJhcmi$9kwihx=!RV3`cG_4TBwH*whx*(Py90C#)xh zlgSNW`rw7L0{-$_BG0iEw1#CO=uMl}7L$#D>;zw&8-od-xrs>*&Bq3f{|3_2heo%9 z@J**BXdLF2;7d%w!DBzkb@|50hT;md`VC$m0DhccBRV=b*+(!AV^MFcpMk65`miva zj*dnK4`a}9dNSz@ho{1fLh_Q0TDBo_b=c0<{KWJoDQLfv&eP#y4d3R#*?HMGJ0`yQv(f5Cie3S z6#qk+j7knk845VOQ9pWgD0=4X2>(x1jktl2g=ecf8^{x^RJWLTKsfjnDuhoCJclh% zxX*4MXmHF+krfI1h5^@S%1}(fc0>u+`dIPN5tA?n4VKU+&x3MiC4Uj0hwVdd>Jr`4 z&{Qt7RH*LY2Y(K<#DB5Y?sDj9#2x5>`a;wL$#t~VU0aUK;_)}|`h*=uUJjb0R>lGb z$0rq=G3(w3{> zZyay&vf$Yl|1II(*a-X+1wp; kf2gJ+y3vImP>+M>_E5||L8C-O87@YP;y)}7>-u>1FAOYC { [loading, hasMore] ); + // Consolidate sequential duplicate rows (issue #161). Runs over the FULL accumulated + // logs array, so groups that straddle a pagination boundary heal automatically once the + // next page appends. Sequential adjacency is only meaningful under the default time sort; + // under domain/client_ip sort every row stays un-merged (identical to before this feature). + const displayGroups = useMemo( + () => (sortValue === "created" ? consolidateLogs(logs) : logs.map(toSingletonGroup)), + [logs, sortValue] + ); + const activeProfile = useAppStore((state) => state.activeProfile); const { setActiveProfile } = useAppStore(); @@ -471,12 +481,13 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => {
)} - {logs.map((log, index) => { - const isLast = index === logs.length - 1; + {displayGroups.map((group, index) => { + const isLast = index === displayGroups.length - 1; return ( 1` the row shows a + * ×N badge and the expanded panel aggregates the members. Omitted / count 1 → single-entry + * row, rendered identically to before this feature. + */ + group?: ConsolidatedLogGroup; isLast?: boolean; lastLogRef?: (node: HTMLDivElement | null) => void; onQuickRule?: (domain?: string, defaultAction?: "denylist" | "allowlist") => void; @@ -22,7 +29,10 @@ interface QueryLogCardProps { onExpand?: () => void; } -const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricted, blocklistNames, serviceNames, onExpand }: QueryLogCardProps): JSX.Element | null => { +const QueryLogCard = ({ log, group, isLast, lastLogRef, onQuickRule, quickRuleRestricted, blocklistNames, serviceNames, onExpand }: 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; // If domain logging is disabled, dns_request.domain may be absent. Provide a placeholder. const rawDomain = log.dns_request?.domain; const normalizedDomain = rawDomain ? rawDomain.replace(/\.$/, "") : undefined; @@ -118,7 +128,9 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte "font-text-xs-leading-4-semibold font-semibold text-[10px] md:text-[length:var(--text-xs-leading-4-semibold-font-size)] tracking-wide leading-4 md:leading-[var(--text-xs-leading-4-semibold-line-height)] uppercase whitespace-nowrap", dnssecShown ? (dnssecFailed ? "text-[var(--tailwind-colors-red-600)]" : "text-[var(--tailwind-colors-rdns-600)]") - : "opacity-0 pointer-events-none select-none", + // Reserve placeholder (desktop only): take no vertical line in the tablet + // stack (md), but keep reserving horizontal space in the lg row. + : "opacity-0 pointer-events-none select-none md:hidden lg:inline-block", className, )} > @@ -145,6 +157,39 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte ? { text: 'Validated', className: 'text-[var(--tailwind-colors-rdns-600)]' } : { text: 'No DNSSEC', className: 'text-[var(--tailwind-colors-slate-200)]' }; + // Consolidation badge: a small non-interactive "×N" pill shown next to the domain when + // this card merges multiple adjacent duplicate queries. Styled like the protocol/DNSSEC + // micro-labels. Plain text, so it can sit under the whole-card toggle overlay. + const renderCountBadge = (className?: string) => { + if (!isConsolidated) return null; + return ( + + ×{count} + + ); + }; + + // Expanded-panel field values: aggregate across members when consolidated, else fall back + // to the representative's single value. + const queryTypeText = isConsolidated ? group?.queryTypes.join(', ') : log.dns_request?.query_type; + const responseCodeText = isConsolidated ? group?.responseCodes.join(', ') : log.dns_request?.response_code; + const timeText = (() => { + if (isConsolidated && group?.firstTimestamp && group?.lastTimestamp) { + const first = format(parseISO(group.firstTimestamp), "MMMM d, yyyy 'at' hh:mm:ss a"); + const last = format(parseISO(group.lastTimestamp), "hh:mm:ss a"); + return `${first} – ${last}`; + } + return log.timestamp ? format(parseISO(log.timestamp), "MMMM d, yyyy 'at' hh:mm:ss a") : "—"; + })(); + return (
-
+
{displayDomain ? (
@@ -224,6 +270,7 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte ) : ( '-' )} + {renderCountBadge()}
@@ -236,12 +283,12 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
{!isMobile && (
-
-
+
+
{protocolLabel}
- {renderDnssecBadge("order-2 md:order-2", true)} - + {renderDnssecBadge("order-2", true)} + Blocked
@@ -280,13 +327,14 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
Domain logging disabled
)} - {log.dns_request?.query_type && renderDetailField("Query type", log.dns_request.query_type, "querylog-detail-query-type")} - {log.dns_request?.response_code && renderDetailField("Response code", log.dns_request.response_code, "querylog-detail-response-code")} + {queryTypeText && renderDetailField(isConsolidated ? "Query types" : "Query type", queryTypeText, "querylog-detail-query-type")} + {responseCodeText && renderDetailField(isConsolidated ? "Response codes" : "Response code", responseCodeText, "querylog-detail-response-code")} {(log.dns_request?.dnssec !== undefined || dnssecFailed) && renderDetailField("DNSSEC", dnssecDetail.text, "querylog-detail-dnssec", dnssecDetail.className)} {renderDetailField("Protocol", protocolLabel, "querylog-detail-protocol")} + {isConsolidated && renderDetailField("Occurrences", String(count), "querylog-detail-occurrences")} {log.client_ip && renderDetailField("Client IP", log.client_ip, "querylog-detail-client-ip")} {log.device_id && renderDetailField("Device ID", log.device_id, "querylog-detail-device-id")} - {renderDetailField("Time", log.timestamp ? format(parseISO(log.timestamp), "MMMM d, yyyy 'at' hh:mm:ss a") : "—", "querylog-detail-timestamp")} + {renderDetailField(isConsolidated ? "Time range" : "Time", timeText, "querylog-detail-timestamp")} {hasReasons && (
From 565cd1a9f1d8d1346f95d7fdb9a04bf16a9cc093 Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 15 Jul 2026 11:24:51 +0200 Subject: [PATCH 2/3] fix(app): Show single time for one-second consolidated log groups Signed-off-by: Maciek --- app/src/__tests__/unit/QueryLogCard.test.tsx | 18 +++++++++++++++- app/src/pages/logs/QueryLogCard.tsx | 22 ++++++++++++-------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/app/src/__tests__/unit/QueryLogCard.test.tsx b/app/src/__tests__/unit/QueryLogCard.test.tsx index 9efd71be..c8891f50 100644 --- a/app/src/__tests__/unit/QueryLogCard.test.tsx +++ b/app/src/__tests__/unit/QueryLogCard.test.tsx @@ -289,8 +289,24 @@ describe('QueryLogCard consolidation (issue #161)', () => { expect(screen.getByTestId('querylog-detail-query-type')).toHaveTextContent('A, AAAA'); expect(screen.getByTestId('querylog-detail-response-code')).toHaveTextContent('NOERROR, NXDOMAIN'); expect(screen.getByTestId('querylog-detail-occurrences')).toHaveTextContent('3'); - // Time range renders both endpoints separated by an en dash. + // group spans 2s (10:20:30 → 10:20:32) → a time RANGE with an en dash and "Time range" label. expect(screen.getByTestId('querylog-detail-timestamp').textContent).toMatch(/–/); + expect(screen.getByText('Time range')).toBeInTheDocument(); + }); + + test('a group whose members share the same second shows a single "Time", not a range', () => { + // A + AAAA fired back-to-back: same second, differing only in milliseconds. + const sameSecondGroup = { + ...group, + firstTimestamp: '2026-06-15T10:20:32.480Z', + lastTimestamp: '2026-06-15T10:20:32.010Z', + }; + render(); + fireEvent.click(screen.getByTestId('querylog-card-toggle')); + // No en dash → single time; label is the plain "Time" (exact, not "Time range"). + expect(screen.getByTestId('querylog-detail-timestamp').textContent).not.toMatch(/–/); + expect(screen.getByText('Time')).toBeInTheDocument(); + expect(screen.queryByText('Time range')).not.toBeInTheDocument(); }); }); diff --git a/app/src/pages/logs/QueryLogCard.tsx b/app/src/pages/logs/QueryLogCard.tsx index 040c1a8e..80b1ed5b 100644 --- a/app/src/pages/logs/QueryLogCard.tsx +++ b/app/src/pages/logs/QueryLogCard.tsx @@ -181,14 +181,18 @@ const QueryLogCard = ({ log, group, isLast, lastLogRef, onQuickRule, quickRuleRe // to the representative's single value. const queryTypeText = isConsolidated ? group?.queryTypes.join(', ') : log.dns_request?.query_type; const responseCodeText = isConsolidated ? group?.responseCodes.join(', ') : log.dns_request?.response_code; - const timeText = (() => { - if (isConsolidated && group?.firstTimestamp && group?.lastTimestamp) { - const first = format(parseISO(group.firstTimestamp), "MMMM d, yyyy 'at' hh:mm:ss a"); - const last = format(parseISO(group.lastTimestamp), "hh:mm:ss a"); - return `${first} – ${last}`; - } - return log.timestamp ? format(parseISO(log.timestamp), "MMMM d, yyyy 'at' hh:mm:ss a") : "—"; - })(); + // A group only shows a first–last time RANGE when its endpoints differ at second granularity. + // A + AAAA fired back-to-back land in the same second, so those collapse to a single "Time" + // (like a non-consolidated row); groups that genuinely span >=1s (e.g. grouped blocked queries) + // keep the range. + const secKey = (ts?: string) => (ts ? format(parseISO(ts), "yyyy-MM-dd'T'HH:mm:ss") : undefined); + const hasTimeRange = Boolean( + isConsolidated && group?.firstTimestamp && group?.lastTimestamp && + secKey(group.firstTimestamp) !== secKey(group.lastTimestamp) + ); + const timeText = hasTimeRange + ? `${format(parseISO(group!.firstTimestamp!), "MMMM d, yyyy 'at' hh:mm:ss a")} – ${format(parseISO(group!.lastTimestamp!), "hh:mm:ss a")}` + : (log.timestamp ? format(parseISO(log.timestamp), "MMMM d, yyyy 'at' hh:mm:ss a") : "—"); return (
{hasReasons && (
From 9b1a4a18733748ebf8f0aa5786629f8c039f7b1a Mon Sep 17 00:00:00 2001 From: Maciek Date: Wed, 15 Jul 2026 11:57:21 +0200 Subject: [PATCH 3/3] fix(app): Cap consolidated query-log group time span at 10s Signed-off-by: Maciek --- .../unit/lib/consolidateLogs.test.ts | 52 ++++++++++++++++++ app/src/lib/consolidateLogs.ts | Bin 4958 -> 6212 bytes 2 files changed, 52 insertions(+) diff --git a/app/src/__tests__/unit/lib/consolidateLogs.test.ts b/app/src/__tests__/unit/lib/consolidateLogs.test.ts index ba7ebee2..b2df2947 100644 --- a/app/src/__tests__/unit/lib/consolidateLogs.test.ts +++ b/app/src/__tests__/unit/lib/consolidateLogs.test.ts @@ -103,6 +103,58 @@ describe('consolidateLogs', () => { expect(consolidateLogs([])).toEqual([]); }); + it('does not merge same-domain entries more than the span window apart', () => { + // Blocked-filter scenario: two blocks of the same domain 5 minutes apart become adjacent + // in the filtered stream, but must NOT merge (default 10s window). + const groups = consolidateLogs([ + log({ domain: 'ads.tracker.com', status: 'blocked', timestamp: '2026-06-15T11:38:09.000Z' }), + log({ domain: 'ads.tracker.com', status: 'blocked', timestamp: '2026-06-15T11:33:09.000Z' }), + ]); + expect(groups).toHaveLength(2); + expect(groups.every((g) => g.count === 1)).toBe(true); + }); + + it('splits a domain blocked repeatedly over an hour into one row per block', () => { + const base = Date.parse('2026-06-15T11:38:09.000Z'); + const items = Array.from({ length: 8 }, (_, i) => + // ~8 minutes apart, newest first (created-desc). + log({ domain: 'ads.tracker.com', status: 'blocked', timestamp: new Date(base - i * 8 * 60_000).toISOString() }) + ); + const groups = consolidateLogs(items); + expect(groups).toHaveLength(8); + expect(groups.every((g) => g.count === 1)).toBe(true); + }); + + it('measures the span from the run first member, not the previous member', () => { + // 12:00:00 anchors the run. 11:59:55 is 5s away → merges. 11:59:48 is only 7s from the + // previous member but 12s from the anchor → it starts a new group. + const groups = consolidateLogs([ + log({ domain: 'a.com', query_type: 'A', timestamp: '2026-06-15T12:00:00.000Z' }), + log({ domain: 'a.com', query_type: 'AAAA', timestamp: '2026-06-15T11:59:55.000Z' }), + log({ domain: 'a.com', query_type: 'A', timestamp: '2026-06-15T11:59:48.000Z' }), + ]); + expect(groups.map((g) => g.count)).toEqual([2, 1]); + }); + + it('respects a custom span window', () => { + const items = [ + log({ domain: 'a.com', query_type: 'A', timestamp: '2026-06-15T12:00:00.000Z' }), + log({ domain: 'a.com', query_type: 'AAAA', timestamp: '2026-06-15T11:59:30.000Z' }), + ]; + // 30s apart: outside the default 10s window (2 groups) but inside a 60s window (1 group). + expect(consolidateLogs(items)).toHaveLength(2); + expect(consolidateLogs(items, 60_000)).toHaveLength(1); + }); + + it('still merges a sub-second A + AAAA pair', () => { + const groups = consolidateLogs([ + log({ domain: 'example.com', query_type: 'A', timestamp: '2026-06-15T10:00:00.400Z' }), + log({ domain: 'example.com', query_type: 'AAAA', timestamp: '2026-06-15T10:00:00.000Z' }), + ]); + expect(groups).toHaveLength(1); + expect(groups[0].count).toBe(2); + }); + it('toSingletonGroup wraps one log as a count-1 group', () => { const g = toSingletonGroup(log({ domain: 'a.com', query_type: 'A' }), 0); expect(g.count).toBe(1); diff --git a/app/src/lib/consolidateLogs.ts b/app/src/lib/consolidateLogs.ts index f5883f7a6d12fee3ba18dadf84b43ae5d3a7e9f5..38bbff1dc7d49a778849272990e74eb57ac11aa0 100644 GIT binary patch delta 1278 zcmZuxJ#Q015S0*$kc9+@f_7+K#GJ=GUH*X7ep1dlBT*bOe{p>lXqCGh-rQ_5{vmaX_gsc)rMi+ZjiX90}b5#iWT=rVa z%QWPopsDh?(ugUC|2i{MGQ(NOAv@LpuVE?DG8N}z6e<(3boBZiIRlQFDH->Z!lXNr zk(El=O9n_Y5gTBb3Zq90fz2r%Dz32>+#7YpS);LH*rE;zw3U(1QEUOx&>4(wA8dUE>d?!(Z8n4b} zBxf@~ra28Pw+3|+UF8Nmh&T>(&xDfrFY0yS<7U>`=Uq-*#MGfArkl60RM2L%_3H4) z!siR^cKCh}%oPMb6%CC38P&nJi`Nc5UHUbHHuoDDj=j+s>%>xY>b#PDn!|bh!CJks zDeFvSVFzoI9UHFkZ%sT*2lY?S0g^A1=phw77`F k&{dE#YQWsn-m^R1@J!XK(Z(Y%n*TgIf%x$2<@e`)19}I#1poj5 delta 25 hcmX?Na8GT+Beu;}Tst@x%Ly}1wh`6cyjFBN699&t2|NG*