diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf65254e..a15e59b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Performance +- **Explore 10k-row rendering** — Compact log results now virtualize rows and + cache row formatting/highlights. The local `agent-browser` 10k benchmark + rendered `29` row nodes and `2,157` DOM elements on this branch, compared + with `10,000` row nodes and `271,211` DOM elements on `main`. See + [the detailed benchmark report](docs/benchmarks/explore-10k-rendering.md). +- **Explore table view rendering** — Table mode now pre-pages rows before + TanStack Table sees them and mounts copy/filter cell actions only on hover, + reducing idle table DOM in the 10k local benchmark from `9,284` elements to + `3,431`. + ## [1.6.0] - 2026-05-06 LogChef 1.6 narrows the **Team** abstraction to access control only and adds diff --git a/docs/benchmarks/explore-10k-rendering.md b/docs/benchmarks/explore-10k-rendering.md new file mode 100644 index 000000000..901922dff --- /dev/null +++ b/docs/benchmarks/explore-10k-rendering.md @@ -0,0 +1,139 @@ +# Explore 10k Rendering Benchmark + +Date: 2026-05-07 + +This benchmark compares the Explore compact log list on `main` with the +`chore/explore-perf` working tree. The goal is to measure browser-side behavior +when the user runs a 10k-row query, not raw ClickHouse throughput. + +## Summary + +The backend query time is effectively unchanged between branches: about `58 ms` +to `59 ms` for 10k rows. The improvement is in frontend rendering and main +thread pressure. + +| Metric | `main` average | `chore/explore-perf` average | Delta | +| --- | ---: | ---: | ---: | +| Rendered log row DOM nodes | `10,000` | `29` | `99.71%` fewer | +| Total DOM elements after render | `271,211` | `2,157` | `99.20%` fewer | +| Used JS heap after render | `323.7 MB` | `95.0 MB` | `70.65%` lower | +| Run click to result paint | `2,459.0 ms` | `1,113.5 ms` | `54.72%` faster | +| Browser-observed query XHR | `2,237.0 ms` | `1,041.8 ms` | `53.43%` faster | +| Backend execution time | `58.0 ms` | `59.0 ms` | roughly equal | +| Long-task blocking time | `2,211.7 ms` | `109.0 ms` | `95.07%` lower | + +The largest win is row virtualization: `main` keeps all 10,000 rows in the DOM, +while the perf branch keeps only the visible rows plus overscan. + +## Additional Sanity Checks + +After the main 10k query benchmark, the following interaction checks were run +against the perf branch to cover cases not exercised by the run-to-paint test. + +| Check | Result | +| --- | --- | +| 200-row query | `190.8 ms` from Run click to paint; backend execution `17 ms`; `29` rendered row nodes; `2,157` DOM elements; one `76 ms` long task | +| 10k top-to-bottom scroll traversal | 90-frame scrollbar-style traversal completed in `1,872.9 ms`; visible indexes moved from the top of the result set to `9972-9999`; rendered row nodes stayed between `28` and `40`; zero observed long tasks | +| Row expansion | Clicking a compact row expanded it and showed the `Show Context` action | +| Context modal | Clicking `Show Context` opened the `Log Context` modal and loaded surrounding rows | + +The scroll traversal originally exposed avoidable work from measuring every +virtual row and rendering logfmt highlight markup while scrolling. The perf +branch now measures only expanded rows and renders plain compact text during +active scrolling, restoring highlighted markup after scroll settles. + +## Table View Follow-up + +The headline benchmark above is for compact mode. Table mode has a different +cost profile because it renders richer cells and per-cell actions. + +After direct table-mode testing with the same 10k query, the table was changed +to pre-page rows before handing them to TanStack Table and to mount copy/filter +cell actions only for the hovered cell. This keeps table mode from building +hidden action controls for every visible cell. + +| Table-mode check | Before | After | +| --- | ---: | ---: | +| Total DOM elements | `9,284` | `3,431` | +| Mounted cell action groups | `500` | `0` idle, `1` on hover | +| Run click to result paint | `817.5 ms` | `715.4 ms` | +| Browser-observed query XHR | `616.5 ms` | `491.3 ms` | +| Long-task blocking time | `868 ms` | `619 ms` | + +Table mode is still intentionally heavier than compact mode: it renders a +paginated grid with rich cells, column resize/reorder, expansion, and +copy/filter actions. For large scans, compact mode remains the smoother default +view for continuous scrolling. + +## Environment + +- Browser automation: `agent-browser` +- Browser: Headless Chrome `148.0.0.0` +- Viewport: `1440 x 900` +- App mode: Explore compact results +- Route: `/logs/explore?team=1&source=1&t=90d&limit=10000` +- `main`: clean worktree at `79e7cf5`, served from `/tmp/logchef-main` on port + `5174` +- Perf branch: `chore/explore-perf` working tree, served on port `5173` +- API: local backend on port `8125` +- Dataset: local ClickHouse dev source `default.http` +- Matching rows in the 90-day window: `441,350` +- Query response size: `2,003,295` response characters, `1,992,436` bytes + returned by the API stats + +The existing dev dataset is large enough for this benchmark. No additional log +ingestion is required for a `limit=10000` test. + +## Method + +The browser was opened to the same route on both branches with the same local +preferences: + +- `display_mode = compact` +- fields panel closed +- relative time range `90d` +- limit `10000` +- empty LogChefQL query, so the backend returns the latest 10k rows in the time + window + +For each branch, the page was loaded until the 10k result state was visible. +Then `agent-browser eval` patched `XMLHttpRequest` in the page, clicked the +visible `Run` button, waited for the LogChefQL query request to finish, and +sampled DOM, heap, backend stats, and long tasks after two +`requestAnimationFrame` turns plus a short settle delay. + +`Browser-observed query XHR` is not pure network time. It includes browser main +thread delay before XHR completion can be observed. The backend's own execution +time comes from the API response `stats.execution_time_ms`. + +## Raw Runs + +### `chore/explore-perf` + +| Run | Row nodes | DOM elements | Heap used | Run to query end | Run to paint | Query XHR | Backend exec | Long tasks | Long-task total | Max long task | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | `29` | `2,157` | `85 MB` | `1,069.1 ms` | `1,125.2 ms` | `1,043.1 ms` | `47 ms` | `1` | `132 ms` | `132 ms` | +| 2 | `29` | `2,157` | `94 MB` | `1,052.2 ms` | `1,114.3 ms` | `1,039.0 ms` | `64 ms` | `1` | `101 ms` | `101 ms` | +| 3 | `29` | `2,157` | `106 MB` | `1,056.8 ms` | `1,101.0 ms` | `1,043.3 ms` | `66 ms` | `1` | `94 ms` | `94 ms` | + +### `main` + +| Run | Row nodes | DOM elements | Heap used | Run to query end | Run to paint | Query XHR | Backend exec | Long tasks | Long-task total | Max long task | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | `10,000` | `271,211` | `242 MB` | `2,526.5 ms` | `2,741.2 ms` | `2,518.6 ms` | `51 ms` | `5` | `2,459 ms` | `1,656 ms` | +| 2 | `10,000` | `271,211` | `323 MB` | `2,136.8 ms` | `2,311.8 ms` | `2,129.3 ms` | `63 ms` | `6` | `1,931 ms` | `1,187 ms` | +| 3 | `10,000` | `271,211` | `406 MB` | `2,069.4 ms` | `2,324.0 ms` | `2,063.2 ms` | `60 ms` | `7` | `2,245 ms` | `1,382 ms` | + +## Interpretation + +The API returns the same 10k rows on both branches and ClickHouse execution time +is nearly identical. The main branch pays the browser cost of maintaining and +updating 10,000 rendered rows, which creates about `271k` DOM elements and +multi-second long tasks. + +The perf branch keeps the result set in memory but only renders the visible +rows. That drops DOM size by two orders of magnitude, cuts used heap by about +`229 MB` on average, and removes almost all long-task blocking during the run. + +These numbers are local dev measurements, so they should be treated as +branch-to-branch comparisons rather than production latency guarantees. diff --git a/frontend/bun.lock b/frontend/bun.lock index 355fab00f..54dfff129 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -5,56 +5,57 @@ "": { "name": "frontend", "dependencies": { - "@dnd-kit/core": "latest", - "@dnd-kit/sortable": "latest", - "@dnd-kit/utilities": "latest", - "@guolao/vue-monaco-editor": "latest", - "@internationalized/date": "latest", - "@tanstack/vue-table": "latest", - "@types/lodash": "latest", - "@types/lodash-es": "latest", - "@types/nearley": "latest", - "@unovis/ts": "latest", - "@unovis/vue": "latest", - "@vee-validate/zod": "latest", - "@vueuse/core": "latest", - "axios": "latest", - "class-variance-authority": "latest", - "clsx": "latest", - "date-fns": "latest", - "highlight.js": "latest", - "lodash": "latest", - "lodash-es": "latest", - "lucide-vue-next": "latest", - "monaco-editor": "latest", - "pinia": "latest", - "reka-ui": "latest", - "tailwind-merge": "latest", - "tw-animate-css": "latest", - "vaul-vue": "latest", - "vee-validate": "latest", - "vue": "latest", - "vue-json-pretty": "latest", - "vue-router": "latest", - "vue-sonner": "latest", - "zod": "latest", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@guolao/vue-monaco-editor": "^1.6.0", + "@internationalized/date": "^3.12.0", + "@tanstack/vue-table": "^8.21.3", + "@tanstack/vue-virtual": "^3.13.13", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@types/nearley": "^2.11.5", + "@unovis/ts": "^1.6.4", + "@unovis/vue": "^1.6.4", + "@vee-validate/zod": "^4.15.1", + "@vueuse/core": "^14.2.1", + "axios": "^1.14.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "highlight.js": "^11.11.1", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lucide-vue-next": "^1.0.0", + "monaco-editor": "^0.55.1", + "pinia": "^3.0.4", + "reka-ui": "^2.9.4", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", + "vaul-vue": "^0.4.1", + "vee-validate": "^4.15.1", + "vue": "^3.5.32", + "vue-json-pretty": "^2.6.0", + "vue-router": "^5.0.4", + "vue-sonner": "^2.0.9", + "zod": "4.3.6", }, "devDependencies": { - "@tailwindcss/vite": "latest", - "@types/node": "latest", - "@vitejs/plugin-vue": "latest", - "@vitest/coverage-v8": "latest", - "@vue/tsconfig": "latest", - "esbuild": "latest", - "jsdom": "latest", - "rolldown-vite": "latest", - "rollup-plugin-visualizer": "latest", - "tailwindcss": "latest", - "ts-node": "latest", - "typescript": "latest", - "vite": "latest", - "vitest": "latest", - "vue-tsc": "latest", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^25.5.2", + "@vitejs/plugin-vue": "^6.0.5", + "@vitest/coverage-v8": "4.1.2", + "@vue/tsconfig": "^0.9.1", + "esbuild": "^0.28.0", + "jsdom": "^29.0.2", + "rolldown-vite": "^7.3.1", + "rollup-plugin-visualizer": "^7.0.1", + "tailwindcss": "^4.2.2", + "ts-node": "^10.9.2", + "typescript": "~6.0.2", + "vite": "^8.0.5", + "vitest": "^4.1.2", + "vue-tsc": "^3.2.6", }, }, }, diff --git a/frontend/package.json b/frontend/package.json index 70780cdc7..3c99e8f51 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@guolao/vue-monaco-editor": "^1.6.0", "@internationalized/date": "^3.12.0", "@tanstack/vue-table": "^8.21.3", + "@tanstack/vue-virtual": "^3.13.13", "@types/lodash": "^4.17.24", "@types/lodash-es": "^4.17.12", "@types/nearley": "^2.11.5", diff --git a/frontend/src/api/logchefql.ts b/frontend/src/api/logchefql.ts index 6de79c2bd..d1af2de0c 100644 --- a/frontend/src/api/logchefql.ts +++ b/frontend/src/api/logchefql.ts @@ -76,6 +76,8 @@ export interface QueryResponse { }; query_id?: string; generated_sql?: string; // The SQL that was executed (for "Show SQL" feature) + conditions?: FilterCondition[]; + fields_used?: string[]; } /** @@ -176,4 +178,3 @@ export async function translateWithCache( fields_used: [], }; } - diff --git a/frontend/src/stores/explore.ts b/frontend/src/stores/explore.ts index 5834f7805..c31018320 100644 --- a/frontend/src/stores/explore.ts +++ b/frontend/src/stores/explore.ts @@ -1,7 +1,7 @@ import { defineStore } from "pinia"; -import { computed, watch } from "vue"; +import { computed, markRaw, watch } from "vue"; import { exploreApi } from "@/api/explore"; -import { logchefqlApi } from "@/api/logchefql"; +import { logchefqlApi, type FilterCondition as LogchefQLFilterCondition } from "@/api/logchefql"; import { isCanceledError } from "@/api/error-handler"; import type { ColumnInfo, @@ -88,6 +88,10 @@ export interface ExploreState { logchefqlQuery?: string; sqlQuery: string; sourceId: number; + logchefqlMeta?: { + fieldsUsed: string[]; + conditions: LogchefQLFilterCondition[]; + }; }; lastExecutionTimestamp: number | null; hasExecutedQuery: boolean; @@ -106,6 +110,7 @@ const DEFAULT_QUERY_STATS: QueryStats = { }; const DRAFT_STORAGE_PREFIX = "logchef.explore.draft"; +const DRAFT_SAVE_DELAY_MS = 500; function inferColumnType(value: unknown): string { if (value === null || value === undefined) { @@ -209,6 +214,8 @@ export const useExploreStore = defineStore("explore", () => { }); let suppressedSourceResetId: number | null = null; + let draftSaveTimer: ReturnType | null = null; + let pendingDraftKey: string | null = null; watch( () => contextStore.sourceId, @@ -219,6 +226,7 @@ export const useExploreStore = defineStore("explore", () => { return; } suppressedSourceResetId = null; + flushDraft(pendingDraftKey ?? draftKey(useTeamsStore().currentTeamId, oldSourceId || 0)); onSourceChange(newSourceId || 0); } } @@ -246,16 +254,18 @@ export const useExploreStore = defineStore("explore", () => { const variableStore = useVariableStore(); let suppressSharedVariableTracking = false; - function currentDraftKey(): string | null { - const teamId = useTeamsStore().currentTeamId; - if (!teamId || !sourceId.value) { + function draftKey(teamId: number | null | undefined, sourceIdValue: number | null | undefined): string | null { + if (!teamId || !sourceIdValue) { return null; } - return `${DRAFT_STORAGE_PREFIX}.${teamId}.${sourceId.value}`; + return `${DRAFT_STORAGE_PREFIX}.${teamId}.${sourceIdValue}`; } - function persistDraft() { - const key = currentDraftKey(); + function currentDraftKey(): string | null { + return draftKey(useTeamsStore().currentTeamId, sourceId.value); + } + + function persistDraftNow(key = currentDraftKey()) { if (!key) { return; } @@ -289,6 +299,33 @@ export const useExploreStore = defineStore("explore", () => { } } + function persistDraft() { + pendingDraftKey = currentDraftKey(); + if (!pendingDraftKey) { + return; + } + + if (draftSaveTimer) { + clearTimeout(draftSaveTimer); + } + + draftSaveTimer = setTimeout(() => { + const key = pendingDraftKey; + draftSaveTimer = null; + pendingDraftKey = null; + persistDraftNow(key); + }, DRAFT_SAVE_DELAY_MS); + } + + function flushDraft(key = pendingDraftKey ?? currentDraftKey()) { + if (draftSaveTimer) { + clearTimeout(draftSaveTimer); + draftSaveTimer = null; + } + pendingDraftKey = null; + persistDraftNow(key); + } + function restoreDraftForCurrentContext(): boolean { const key = currentDraftKey(); if (!key) { @@ -659,10 +696,10 @@ export const useExploreStore = defineStore("explore", () => { if (mode === currentMode) return; state.data.value.activeMode = mode; clearShareSelectionIfDirty(); - persistDraft(); + flushDraft(); } - function _updateLastExecutedState() { + function _updateLastExecutedState(logchefqlMeta?: { fieldsUsed: string[]; conditions: LogchefQLFilterCondition[] }) { const executedSql = state.data.value.activeMode === 'logchefql' ? (state.data.value.generatedDisplaySql || sqlForExecution.value) : sqlForExecution.value; @@ -673,7 +710,8 @@ export const useExploreStore = defineStore("explore", () => { mode: state.data.value.activeMode, logchefqlQuery: state.data.value.logchefqlCode, sqlQuery: executedSql, - sourceId: sourceId.value + sourceId: sourceId.value, + logchefqlMeta, }; state.data.value.lastExecutionTimestamp = Date.now(); } @@ -1023,6 +1061,8 @@ export const useExploreStore = defineStore("explore", () => { state.data.value.timeRange = { start, end }; } + flushDraft(); + if (state.data.value.currentQueryAbortController) { state.data.value.currentQueryAbortController.abort(); } @@ -1100,9 +1140,9 @@ export const useExploreStore = defineStore("explore", () => { }, { signal: abortController.signal, timeout: queryTimeout }); if (queryResponse.data) { - const logs = queryResponse.data.logs || []; + const logs = markRaw(queryResponse.data.logs || []) as Record[]; state.data.value.logs = logs; - state.data.value.columns = normalizeQueryColumns(queryResponse.data.columns, logs); + state.data.value.columns = markRaw(normalizeQueryColumns(queryResponse.data.columns, logs)); state.data.value.queryStats = queryResponse.data.stats || DEFAULT_QUERY_STATS; state.data.value.queryWarnings = queryResponse.data.warnings || []; @@ -1114,7 +1154,10 @@ export const useExploreStore = defineStore("explore", () => { state.data.value.generatedDisplaySql = queryResponse.data.generated_sql; } - _updateLastExecutedState(); + _updateLastExecutedState({ + fieldsUsed: queryResponse.data.fields_used || [], + conditions: queryResponse.data.conditions || [], + }); persistDraft(); try { @@ -1215,9 +1258,9 @@ export const useExploreStore = defineStore("explore", () => { showToast: false, // Errors shown inline via QueryError component onSuccess: (data: QuerySuccessResponse | null) => { if (data && (data.data || data.logs)) { - const logs = data.data || data.logs || []; + const logs = markRaw(data.data || data.logs || []) as Record[]; state.data.value.logs = logs; - state.data.value.columns = normalizeQueryColumns(data.columns, logs); + state.data.value.columns = markRaw(normalizeQueryColumns(data.columns, logs)); state.data.value.queryStats = data.stats || DEFAULT_QUERY_STATS; state.data.value.queryWarnings = data.warnings || []; if (data.params && typeof data.params === 'object' && "query_id" in data.params) { @@ -1614,6 +1657,7 @@ export const useExploreStore = defineStore("explore", () => { getLogContext, createQueryShare, setActiveShareToken, + flushDraft, clearError, setGroupByField, setTimezoneIdentifier, diff --git a/frontend/src/views/explore/LogExplorer.vue b/frontend/src/views/explore/LogExplorer.vue index 310971d9f..407d891ab 100644 --- a/frontend/src/views/explore/LogExplorer.vue +++ b/frontend/src/views/explore/LogExplorer.vue @@ -18,6 +18,7 @@ import { useSourcesStore } from "@/stores/sources"; import { useSavedQueriesStore } from "@/stores/savedQueries"; import { usePreferencesStore } from "@/stores/preferences"; import { FieldSideBar } from "@/components/field-sidebar"; +import { EmptyState as LayoutEmptyState, LoadingState } from "@/components/layout"; import { getErrorMessage } from "@/api/types"; import DataTable from "./table/data-table.vue"; import CompactLogList from "./table/CompactLogListSimple.vue"; @@ -28,18 +29,15 @@ import { useUrlState } from "@/composables/useUrlState"; import { useQuery } from "@/composables/useQuery"; import { useTimeRange } from "@/composables/useTimeRange"; import { useVariables } from "@/composables/useVariables"; +import { useExploreQueryParsing } from "./composables/useExploreQueryParsing"; import { useContextStore } from "@/stores/context"; import { exploreApi } from "@/api/explore"; import type { ComponentPublicInstance } from "vue"; import type { SaveQueryFormData } from "@/views/explore/types"; import type { SavedQuery } from "@/api/savedQueries"; -import { logchefqlApi, type FilterCondition } from "@/api/logchefql"; import { generateCliCommand } from "@/utils/cliCommand"; -// Type alias for backwards compatibility -type QueryCondition = FilterCondition; - // Import refactored components import TeamSourceSelector from "./components/TeamSourceSelector.vue"; import QueryError from "./components/QueryError.vue"; @@ -47,6 +45,7 @@ import HistogramVisualization from "./components/HistogramVisualization.vue"; import EmptyResultsState from "./components/EmptyResultsState.vue"; import ExploreTopBar from "./components/ExploreTopBar.vue"; import ResultsToolbar from "./components/ResultsToolbar.vue"; +import { Cable, CircleAlert, Database, Info, RefreshCw, Users } from "lucide-vue-next"; // Router and stores const router = useRouter(); @@ -78,6 +77,11 @@ const { handleLimitUpdate: _handleLimitUpdate, } = useQuery(); +const { queryFields, regexHighlights } = useExploreQueryParsing( + activeMode, + computed(() => exploreStore.lastExecutedState) +); + const { handleHistogramTimeRangeZoom } = useTimeRange(); // Use the new clean team/source management @@ -110,6 +114,15 @@ const availableFields = computed(() => { return [...sourceDetails.value.columns].sort((a, b) => a.name.localeCompare(b.name)); }); +const queryEditorSchema = computed(() => { + return (sourceDetails.value?.columns || []).reduce((acc, col) => { + if (col.name && col.type) { + acc[col.name] = { type: col.type }; + } + return acc; + }, {} as Record); +}); + // Simple loading state for UI (replacement for isChangingContext) const isChangingContext = computed(() => { const teamLoading = sourcesStore.isLoadingTeamSources; @@ -119,55 +132,6 @@ const isChangingContext = computed(() => { const isExporting = ref(false); let exportAbortController: AbortController | null = null; -const getQueryParamValue = (key: string) => { - const value = route.query[key]; - if (Array.isArray(value)) { - return value[0]; - } - return typeof value === "string" ? value : undefined; -}; - -const buildExploreNavigationQuery = ( - overrides: Partial> = {} -) => { - const query: Record = {}; - - for (const key of ["team", "source", "t", "start", "end", "limit", "mode"] as const) { - const value = Object.prototype.hasOwnProperty.call(overrides, key) - ? overrides[key] - : getQueryParamValue(key); - if (value) { - query[key] = value; - } - } - - return query; -}; - -// Simple team/source change handlers using router -function handleTeamChange(teamIdStr: string) { - const teamId = parseInt(teamIdStr); - if (isNaN(teamId)) return; - - router.replace({ - query: buildExploreNavigationQuery({ - team: String(teamId), - source: undefined, - }), - }); -} - -function handleSourceChange(sourceIdStr: string) { - const sourceId = parseInt(sourceIdStr); - if (isNaN(sourceId)) return; - - router.replace({ - query: buildExploreNavigationQuery({ - source: String(sourceId), - }), - }); -} - const { showSaveQueryModal, handleSaveQueryClick: openSaveModalFlow, @@ -177,21 +141,6 @@ const { loadSourceQueries, } = useSavedQueries(); -// Create default empty parsed query state -const EMPTY_PARSED_QUERY = { - success: false, - meta: { fieldsUsed: [], conditions: [] }, -}; - -// Add parsed query structure to highlight columns used in search -const lastParsedQuery = ref<{ - success: boolean; - meta?: { - fieldsUsed: string[]; - conditions: QueryCondition[]; - }; -}>(EMPTY_PARSED_QUERY); - // Basic state // Sidebar defaults to open, but respects user's saved preference const showFieldsPanel = computed({ @@ -298,102 +247,6 @@ const currentQueryContentJson = computed(() => { }); }); -// Update the parsed query whenever a new query is executed -watch( - () => exploreStore.lastExecutedState, - async (newState) => { - if (!newState) { - lastParsedQuery.value = EMPTY_PARSED_QUERY; - return; - } - - if (activeMode.value === "logchefql") { - // Check if query is empty - if (!logchefQuery.value || logchefQuery.value.trim() === "") { - lastParsedQuery.value = EMPTY_PARSED_QUERY; - } else { - // Parse the query using backend LogchefQL API - const teamId = teamsStore.currentTeamId; - const sourceId = currentSourceId.value; - - if (teamId && sourceId) { - try { - const response = await logchefqlApi.translate(teamId, sourceId, { query: logchefQuery.value }); - if (response.data && response.data.valid) { - lastParsedQuery.value = { - success: true, - meta: { - fieldsUsed: response.data.fields_used || [], - conditions: response.data.conditions?.map((c: FilterCondition) => ({ - field: c.field, - operator: c.operator, - value: c.value, - is_regex: c.is_regex - })) || [] - } - }; - } else { - lastParsedQuery.value = EMPTY_PARSED_QUERY; - } - } catch (error) { - console.warn("Failed to parse query via backend:", error); - lastParsedQuery.value = EMPTY_PARSED_QUERY; - } - } else { - lastParsedQuery.value = EMPTY_PARSED_QUERY; - } - } - } else { - // Reset when in SQL mode - lastParsedQuery.value = EMPTY_PARSED_QUERY; - } - }, - { immediate: true } -); - -// Add computed property to get parsed query structure -const parsedQuery = computed(() => { - return lastParsedQuery.value; -}); - -// Use structured data for query fields -const queryFields = computed(() => { - if (!parsedQuery.value.success) return []; - return parsedQuery.value.meta?.fieldsUsed || []; -}); - -// Use structured data for regex patterns -const regexHighlights = computed(() => { - const highlights: Record = - {}; - - if (!parsedQuery.value.success) return highlights; - - // Extract only regex conditions - const regexConditions = (parsedQuery.value.meta?.conditions || []).filter( - (cond: QueryCondition) => cond.is_regex - ); - - // Process each regex condition - regexConditions.forEach((cond: QueryCondition) => { - let pattern = cond.value; - // Remove quotes if they exist - if ( - (pattern.startsWith('"') && pattern.endsWith('"')) || - (pattern.startsWith("'") && pattern.endsWith("'")) - ) { - pattern = pattern.slice(1, -1); - } - - highlights[cond.field] = { - pattern, - isNegated: cond.operator === "!~", - }; - }); - - return highlights; -}); - // Function to execute a query and handle URL history // Modify the function to include a debouncingKey parameter to prevent duplicate executions const handleQueryExecution = async (debouncingKey = "") => { @@ -476,25 +329,6 @@ watch( { immediate: false } ) -// Keep store selection in sync with URL when team/source query params change -watch( - () => [route.query.team, route.query.source], - async ([teamParam, sourceParam]) => { - if (isInitializing.value) return; - const t = teamParam ? parseInt(teamParam as string) : null; - const s = sourceParam ? parseInt(sourceParam as string) : null; - if (t && t !== currentTeamId.value) { - await handleTeamChange(t.toString()); - // If URL includes a specific source, switch to it after team change - if (s) { - await handleSourceChange(s.toString()); - } - } else if (s && s !== currentSourceId.value) { - await handleSourceChange(s.toString()); - } - } -) - // Function to handle drill-down from DataTable to add a filter condition const handleDrillDown = (data: { column: string; @@ -772,6 +606,7 @@ const handleExport = async () => { }; onBeforeUnmount(() => { + exploreStore.flushDraft(); exportAbortController?.abort(); exportAbortController = null; }); @@ -1072,6 +907,40 @@ const onSaveQueryModalSave = (formData: SaveQueryFormData) => { processSaveQueryFromComposable(formData); }; +function isRouteContextAligned() { + const routeTeam = route.query.team ? parseInt(route.query.team as string, 10) : null; + const routeSource = route.query.source ? parseInt(route.query.source as string, 10) : null; + + return !!routeTeam && + !!routeSource && + routeTeam === (currentTeamId.value ?? 0) && + routeSource === (currentSourceId.value ?? 0); +} + +function waitForRouteContextAlignment(maxWaitMs = 500): Promise { + if (isRouteContextAligned()) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const stop = watch( + [() => route.query.team, () => route.query.source, currentTeamId, currentSourceId], + () => { + if (isRouteContextAligned()) { + clearTimeout(timeout); + stop(); + resolve(); + } + } + ); + + const timeout = setTimeout(() => { + stop(); + resolve(); + }, maxWaitMs); + }); +} + // Handle saved query id changes from URL, especially when component is kept alive watch( () => route.query.id, @@ -1093,16 +962,7 @@ watch( let urlSource = route.query.source ? parseInt(route.query.source as string) : null; if (!urlTeam || !urlSource || urlTeam !== currentTeamId.value || urlSource !== currentSourceId.value) { - for (let i = 0; i < 5; i++) { - await new Promise(r => setTimeout(r, 100)); - if ( - route.query.team && route.query.source && - parseInt(route.query.team as string) === (currentTeamId.value ?? 0) && - parseInt(route.query.source as string) === (currentSourceId.value ?? 0) - ) { - break; - } - } + await waitForRouteContextAlignment(); // Recompute after polling to avoid stale values urlTeam = route.query.team ? parseInt(route.query.team as string) : null; urlSource = route.query.source ? parseInt(route.query.source as string) : null; @@ -1167,59 +1027,38 @@ onMounted(async () => { @@ -1530,8 +1318,7 @@ onMounted(async () => { - - +