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..c8891f50 100644
--- a/app/src/__tests__/unit/QueryLogCard.test.tsx
+++ b/app/src/__tests__/unit/QueryLogCard.test.tsx
@@ -238,6 +238,78 @@ 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');
+ // 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();
+ });
+});
+
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..b2df2947
--- /dev/null
+++ b/app/src/__tests__/unit/lib/consolidateLogs.test.ts
@@ -0,0 +1,164 @@
+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('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);
+ 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 00000000..38bbff1d
Binary files /dev/null and b/app/src/lib/consolidateLogs.ts differ
diff --git a/app/src/pages/logs/Logs.tsx b/app/src/pages/logs/Logs.tsx
index 26a564eb..89210cb4 100644
--- a/app/src/pages/logs/Logs.tsx
+++ b/app/src/pages/logs/Logs.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState, useCallback, type JSX } from "react";
+import { useEffect, useRef, useState, useCallback, useMemo, type JSX } from "react";
import type { AxiosError } from "axios";
interface NetworkError extends AxiosError { code?: string; }
@@ -11,6 +11,7 @@ import NoLogs from "./NoLogs";
import LogsNotActive from "./LogsNotActive";
import QueryLogCard from "./QueryLogCard";
import QuickRuleSheet, { type QuickRuleAction } from "./QuickRuleSheet";
+import { consolidateLogs, toSingletonGroup } from "@/lib/consolidateLogs";
import api from "@/api/api";
import { useAppStore } from "@/store/general";
import { Skeleton } from "@/components/ui/skeleton";
@@ -91,6 +92,15 @@ const QueryLogs = ({ profiles }: QueryLogsProps): JSX.Element => {
[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,43 @@ 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;
+ // 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 (
-
@@ -224,6 +274,7 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
) : (
'-'
)}
+ {renderCountBadge()}
@@ -236,12 +287,12 @@ const QueryLogCard = ({ log, isLast, lastLogRef, onQuickRule, quickRuleRestricte
{!isMobile && (
-
-
+
+
{protocolLabel}
- {renderDnssecBadge("order-2 md:order-2", true)}
-
+ {renderDnssecBadge("order-2", true)}
+
Blocked
@@ -280,13 +331,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(hasTimeRange ? "Time range" : "Time", timeText, "querylog-detail-timestamp")}
{hasReasons && (