From 2b6ae03d21998afe893f97f06eeb2fdca5f2daea Mon Sep 17 00:00:00 2001 From: Ildar Kamalov Date: Thu, 25 Jun 2026 18:53:17 +0300 Subject: [PATCH] fix query log filters --- .../src/__tests__/query-log-store.test.ts | 54 ----------- client_v3/src/__tests__/queryLogStore.test.ts | 74 +++++++++++++-- .../src/components/QueryLog/QueryLog.tsx | 2 +- client_v3/src/stores/queryLogs.ts | 89 +++++++++++-------- 4 files changed, 122 insertions(+), 97 deletions(-) delete mode 100644 client_v3/src/__tests__/query-log-store.test.ts diff --git a/client_v3/src/__tests__/query-log-store.test.ts b/client_v3/src/__tests__/query-log-store.test.ts deleted file mode 100644 index 41023143..00000000 --- a/client_v3/src/__tests__/query-log-store.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { getAdditionalLogs, setFilteredLogs, queryLogsState } from 'panel/stores/queryLogs'; -import { apiClient } from 'panel/api/Api'; - -vi.mock('panel/api/Api', () => ({ - apiClient: { - getQueryLog: vi.fn(), - }, -})); - -vi.mock('panel/stores/toasts', () => ({ - addErrorToast: vi.fn(), - addSuccessToast: vi.fn(), -})); - -describe('queryLogs store', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('does not mark the log as complete when additional loading stops', async () => { - // Mock the API to return a non-complete log - (apiClient.getQueryLog as any).mockResolvedValue({ - data: [], - oldest: '', - is_entire_log: false, - }); - - // Simulate the state before additional loading - // The store starts with processingAdditionalLogs: false - await getAdditionalLogs(); - - expect(queryLogsState.processingAdditionalLogs).toBe(false); - expect(queryLogsState.isEntireLog).toBe(false); - }); - - it('accumulates pages until oldest is empty (short-polling)', async () => { - (apiClient.getQueryLog as any) - .mockResolvedValueOnce({ - data: [{ reason: 'Rewrite' }], - oldest: 'cur', - is_entire_log: false, - }) - .mockResolvedValueOnce({ - data: [{ reason: 'Rewrite' }], - oldest: '', - is_entire_log: true, - }); - - await setFilteredLogs({ search: '', status: 'rewritten', reason: 'all' }); - expect(apiClient.getQueryLog).toHaveBeenCalledTimes(2); - expect(queryLogsState.processingGetLogs).toBe(false); - }); -}); diff --git a/client_v3/src/__tests__/queryLogStore.test.ts b/client_v3/src/__tests__/queryLogStore.test.ts index 41023143..1c70da35 100644 --- a/client_v3/src/__tests__/queryLogStore.test.ts +++ b/client_v3/src/__tests__/queryLogStore.test.ts @@ -18,16 +18,80 @@ describe('queryLogs store', () => { vi.clearAllMocks(); }); + it("getAdditionalLogs appends filtered rows using the oldest cursor and filter", async () => { + (apiClient.getQueryLog as any).mockReset(); + + // Seed state: a full first page (20 rewritten) with more behind it. + const fullPage = Array.from({ length: 20 }, () => ({ + reason: "Rewrite", + question: {}, + })); + (apiClient.getQueryLog as any).mockResolvedValueOnce({ + data: fullPage, + oldest: "cur", + }); + await setFilteredLogs({ search: "", status: "rewritten", reason: "all" }); + expect(queryLogsState.isEntireLog).toBe(false); + expect(queryLogsState.logs).toHaveLength(20); + + // Load more: one extra rewritten entry, then end of log. + (apiClient.getQueryLog as any).mockResolvedValueOnce({ + data: [{ reason: "Rewrite", question: {} }], + oldest: "", + }); + + await getAdditionalLogs(); + + // The load-more request must carry the cursor and the reason filter. + expect(apiClient.getQueryLog).toHaveBeenLastCalledWith( + expect.objectContaining({ + older_than: "cur", + reason: expect.arrayContaining([ + "Rewrite", + "RewriteEtcHosts", + "RewriteRule", + "FilteredSafeSearch", + ]), + }), + ); + // Must NOT send the deprecated response_status + const lastCall = (apiClient.getQueryLog as any).mock.calls.at(-1)[0]; + expect(lastCall).not.toHaveProperty("response_status"); + + expect(queryLogsState.logs).toHaveLength(21); + expect(queryLogsState.isEntireLog).toBe(true); + expect(queryLogsState.processingAdditionalLogs).toBe(false); + }); + + it("setFilteredLogs sends reason strings for blocked status", async () => { + (apiClient.getQueryLog as any).mockReset(); + (apiClient.getQueryLog as any).mockResolvedValue({ + data: [{ reason: "FilteredBlackList", question: {} }], + oldest: "", + }); + + await setFilteredLogs({ search: "", status: "blocked", reason: "all" }); + + const lastCall = (apiClient.getQueryLog as any).mock.calls.at(-1)[0]; + expect(apiClient.getQueryLog).toHaveBeenLastCalledWith( + expect.objectContaining({ + reason: expect.arrayContaining([ + "FilteredBlackList", + "FilteredSafeBrowsing", + "FilteredParental", + "FilteredBlockedService", + ]), + }), + ); + expect(lastCall).not.toHaveProperty("response_status"); + }); + it('does not mark the log as complete when additional loading stops', async () => { - // Mock the API to return a non-complete log (apiClient.getQueryLog as any).mockResolvedValue({ data: [], - oldest: '', - is_entire_log: false, + oldest: 'next-cursor', }); - // Simulate the state before additional loading - // The store starts with processingAdditionalLogs: false await getAdditionalLogs(); expect(queryLogsState.processingAdditionalLogs).toBe(false); diff --git a/client_v3/src/components/QueryLog/QueryLog.tsx b/client_v3/src/components/QueryLog/QueryLog.tsx index 3df26115..d9d47711 100644 --- a/client_v3/src/components/QueryLog/QueryLog.tsx +++ b/client_v3/src/components/QueryLog/QueryLog.tsx @@ -199,7 +199,7 @@ export const QueryLog = () => { return; } setIsIncrementalLoad(true); - getAdditionalLogs(currentSearch()); + getAdditionalLogs(); }; const allowedClients = (): string[] => { diff --git a/client_v3/src/stores/queryLogs.ts b/client_v3/src/stores/queryLogs.ts index 3bfc0322..aa9fdc7e 100644 --- a/client_v3/src/stores/queryLogs.ts +++ b/client_v3/src/stores/queryLogs.ts @@ -55,55 +55,69 @@ const [state, setState] = createStore(initialState); // ---------- Short-poll helpers (v2 parity) ---------- -const REASON_TO_RESPONSE_STATUS: Record = { - [QUERY_LOG_REASON_FILTER.BLOCKED_BY_FILTER.QUERY]: 'blocked', - [QUERY_LOG_REASON_FILTER.BLOCKED_SERVICES.QUERY]: 'blocked_services', - [QUERY_LOG_REASON_FILTER.BLOCKED_BY_THREATS.QUERY]: 'blocked_safebrowsing', - [QUERY_LOG_REASON_FILTER.BLOCKED_BY_PARENTAL_CONTROL.QUERY]: 'blocked_parental', - [QUERY_LOG_REASON_FILTER.SAFE_SEARCH.QUERY]: 'safe_search', - [QUERY_LOG_REASON_FILTER.DNS_REWRITES.QUERY]: 'rewritten', +/** Maps frontend status filter → exact backend reason strings */ +const STATUS_TO_REASONS: Record = { + blocked: [ + 'FilteredBlackList', + 'FilteredSafeBrowsing', + 'FilteredParental', + 'FilteredBlockedService', + ], + rewritten: [ + 'Rewrite', + 'RewriteEtcHosts', + 'RewriteRule', + 'FilteredSafeSearch', + ], + processed: ['NotFilteredNotFound'], + allowed: ['NotFilteredWhiteList'], + error: ['NotFilteredError', 'FilteredInvalid'], + all: [], }; -const STATUS_TO_RESPONSE_STATUS: Record = { - allowed: 'whitelisted', - processed: 'processed', - blocked: 'blocked', - rewritten: 'rewritten', +/** Maps frontend reason filter query → exact backend reason strings */ +const REASON_FILTER_TO_REASONS: Record = { + [QUERY_LOG_REASON_FILTER.BLOCKED_BY_FILTER.QUERY]: ['FilteredBlackList'], + [QUERY_LOG_REASON_FILTER.BLOCKED_SERVICES.QUERY]: ['FilteredBlockedService'], + [QUERY_LOG_REASON_FILTER.BLOCKED_BY_THREATS.QUERY]: ['FilteredSafeBrowsing'], + [QUERY_LOG_REASON_FILTER.BLOCKED_BY_PARENTAL_CONTROL.QUERY]: [ + 'FilteredParental', + ], + [QUERY_LOG_REASON_FILTER.SAFE_SEARCH.QUERY]: ['FilteredSafeSearch'], + [QUERY_LOG_REASON_FILTER.DNS_REWRITES.QUERY]: [ + 'Rewrite', + 'RewriteEtcHosts', + 'RewriteRule', + ], }; -const getEffectiveResponseStatus = (filter?: any) => { +const getReasons = (filter?: any): string[] => { const reason = filter?.reason ?? DEFAULT_LOGS_FILTER.reason; const status = filter?.status ?? DEFAULT_LOGS_FILTER.status; - if (reason !== 'all') return REASON_TO_RESPONSE_STATUS[reason] ?? 'all'; - return STATUS_TO_RESPONSE_STATUS[status] ?? 'all'; + if (reason !== 'all') { + return REASON_FILTER_TO_REASONS[reason] ?? []; + } + return STATUS_TO_REASONS[status] ?? []; }; const fetchLogsWithParams = async (olderThan: string, filter?: any) => { - const raw = await apiClient.getQueryLog({ + const params: Record = { search: filter?.search ?? DEFAULT_LOGS_FILTER.search, - response_status: getEffectiveResponseStatus(filter), older_than: olderThan, - }); + }; + const reasons = getReasons(filter); + if (reasons.length > 0) { + params.reason = reasons; + } + const raw = await apiClient.getQueryLog(params); return { logs: normalizeLogs(raw.data || []), oldest: raw.oldest || '' }; }; /** Simple stateless filter: count entries matching the status */ const filterLogsByStatus = (logs: any[], status: string): any[] => { if (!status || status === 'all') return logs; - const statusToReason: Record = { - processed: ['Filtered', 'NotFiltered'], - allowed: ['NotFilteredNotFound', 'NotFilteredWhiteList'], - blocked: [ - 'FilteredBlackList', - 'FilteredBlockedService', - 'FilteredSafeBrowsing', - 'FilteredParental', - 'FilteredInvalid', - ], - rewritten: ['Rewrite', 'RewriteEtcHosts', 'RewriteRule'], - }; - const reasons = statusToReason[status]; - if (!reasons) return logs; + const reasons = STATUS_TO_REASONS[status]; + if (!reasons || reasons.length === 0) return logs; return logs.filter((log: any) => reasons.includes(log.reason)); }; @@ -154,14 +168,15 @@ export const getLogs = async (currentQuery?: string) => { } }; -export const getAdditionalLogs = async (params?: any) => { +export const getAdditionalLogs = async () => { setState('processingAdditionalLogs', true); try { - const data = await apiClient.getQueryLog(params); + const { filter, oldest } = untrack(() => state); + const data = await fetchLogsWithParams(oldest, filter); setState({ - logs: [...state.logs, ...normalizeLogs(data.data || [])], - oldest: data.oldest || '', - isEntireLog: data.is_entire_log || false, + logs: [...state.logs, ...data.logs], + oldest: data.oldest, + isEntireLog: data.oldest === '', processingAdditionalLogs: false, }); } catch (error) {