Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions app/src/__tests__/e2e/logs/logs-mobile-overflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
72 changes: 72 additions & 0 deletions app/src/__tests__/unit/QueryLogCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<QueryLogCard log={memberA} />);
expect(screen.queryByTestId('querylog-count-badge')).not.toBeInTheDocument();
render(<QueryLogCard log={memberA} group={{ ...group, count: 1, members: [memberA], queryTypes: ['A'], responseCodes: ['NOERROR'] }} />);
expect(screen.queryByTestId('querylog-count-badge')).not.toBeInTheDocument();
});

test('consolidated row shows a ×N count badge', () => {
render(<QueryLogCard log={memberA} group={group} />);
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(<QueryLogCard log={memberA} group={group} />);
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(<QueryLogCard log={{ ...memberA, timestamp: '2026-06-15T10:20:32.480Z' }} group={sameSecondGroup} />);
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;
Expand Down
33 changes: 32 additions & 1 deletion app/src/__tests__/unit/QueryLogs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<div>
<input
data-testid="search-input"
Expand All @@ -71,6 +72,7 @@ vi.mock("@/pages/logs/Filters", () => ({
/>
<button data-testid="commit-search" onClick={() => onSearchCommit?.()}>Commit</button>
<button data-testid="filter-blocked" onClick={() => onFilterChange?.("blocked")}>Filter Blocked</button>
<button data-testid="sort-domain" onClick={() => onSortChange?.("domain")}>Sort Domain</button>
<button data-testid="timespan-all" onClick={() => onTimespanChange?.("all")}>Timespan</button>
<button data-testid="device-select" onClick={() => onDeviceIdChange?.("device-1")}>Device</button>
<button data-testid="refresh" onClick={() => onRefresh?.()}>Refresh</button>
Expand Down Expand Up @@ -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(<QueryLogs account={account} profiles={[baseProfile]} />);
// 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(<QueryLogs account={account} profiles={[baseProfile]} />);
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: [] });
Expand Down
Loading
Loading