From 25c0fe6382a43b4e47df93173b7e8df8a299e8a7 Mon Sep 17 00:00:00 2001 From: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com> Date: Mon, 23 Feb 2026 16:54:15 +0530 Subject: [PATCH] feat: query builder support on logs page (#10305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #10446 Design at : https://github.com/openobserve/designs/tree/main/auto-query-builder-on-logs-page # Logs Query Builder - Behavior Guide What happens in different scenarios when using the Build tab. --- ## Switching to Build Tab - Scenarios ### Scenario 1: No Query, SQL Mode OFF **Starting state:** - Query editor is empty - SQL Mode is OFF - Stream "default" is selected **What happens when you click Build:** 1. SQL Mode turns ON automatically 2. Query is generated: `SELECT * FROM "default"` 3. Quick Mode turns ON (if enabled in config) 4. Build tab opens in **Custom SQL Mode** 5. Chart type: **Table** (with dynamic columns = true) --- ### Scenario 2: No Query, SQL Mode ON **Starting state:** - Query editor is empty - SQL Mode is ON - Stream "default" is selected **What happens when you click Build:** 1. Query is generated: `SELECT * FROM "default"` 2. Build tab opens in **Builder Mode** 3. Chart type: **Line** (default) --- ### Scenario 3: Simple SELECT * Query **Starting state:** - Query: `SELECT * FROM "logs"` - SQL Mode is ON **What happens when you click Build:** 1. Query is parsed but no explicit fields found (`*` = all columns) 2. Build tab opens in **Custom SQL Mode** 3. Chart type: **Table** (with dynamic columns) 4. Quick Mode turns ON (SELECT * pattern detected) 5. Query editor is **editable** --- ### Scenario 4: Histogram Query (Time Series) **Starting state:** - Query: `SELECT histogram(_timestamp) as time, count(*) as count FROM "logs" GROUP BY time` - SQL Mode is ON **What happens when you click Build:** 1. Query is parsed successfully 2. Build tab opens in **Builder Mode** 3. Chart type: **Bar** (default chart type) 4. Fields populated: X-axis = histogram, Y-axis = count --- ### Scenario 5: Count Only Query (No Grouping) **Starting state:** - Query: `SELECT count(*) as total FROM "logs"` - SQL Mode is ON **What happens when you click Build:** 1. Query is parsed successfully 2. Build tab opens in **Builder Mode** 3. Chart type: **Metric** (big number display - only Y-axis, no X-axis) --- ### Scenario 6: Query with 3+ GROUP BY Fields **Starting state:** - Query: `SELECT status, method, host, count(*) FROM "logs" GROUP BY status, method, host` - SQL Mode is ON **What happens when you click Build:** 1. Query is parsed successfully 2. Build tab opens in **Builder Mode** 3. Chart type: **Table** (too many grouping fields for chart) --- ### Scenario 7: Simple JOIN Query **Starting state:** - Query: `SELECT a.field, b.field FROM "stream1" a JOIN "stream2" b ON a.id = b.id` - SQL Mode is ON **What happens when you click Build:** 1. Query is parsed successfully 2. Build tab opens in **Builder Mode** 3. Chart type: **Bar** (default) 4. JOIN is recognized and shown in builder --- ### Scenario 8: Nested JOIN Query **Starting state:** - Query: `SELECT * FROM "a" JOIN ("b" JOIN "c" ON b.x = c.x) ON a.y = b.y` - SQL Mode is ON **What happens when you click Build:** 1. **Complex pattern detected** (nested JOIN with parentheses) 2. Build tab opens in **Custom SQL Mode** 3. Chart type: **Table** (with dynamic columns) 4. Query editor is **editable** 5. Field selectors are **hidden** --- ### Scenario 9: CTE Query (WITH clause) **Starting state:** - Query: `WITH recent AS (SELECT * FROM "logs" WHERE _timestamp > now() - interval '1 hour') SELECT * FROM recent` - SQL Mode is ON **What happens when you click Build:** 1. **Complex pattern detected** (CTE) 2. Build tab opens in **Custom SQL Mode** 3. Chart type: **Table** (with dynamic columns) 4. Query editor is **editable** --- ### Scenario 10: Subquery **Starting state:** - Query: `SELECT * FROM (SELECT status, count(*) FROM "logs" GROUP BY status) subq` - SQL Mode is ON **What happens when you click Build:** 1. **Complex pattern detected** (subquery) 2. Build tab opens in **Custom SQL Mode** 3. Chart type: **Table** (with dynamic columns) --- ### Scenario 11: UNION Query **Starting state:** - Query: `SELECT * FROM "logs1" UNION SELECT * FROM "logs2"` - SQL Mode is ON **What happens when you click Build:** 1. **Complex pattern detected** (UNION) 2. Build tab opens in **Custom SQL Mode** 3. Chart type: **Table** (with dynamic columns) --- ### Scenario 12: Window Function Query **Starting state:** - Query: `SELECT *, ROW_NUMBER() OVER (PARTITION BY status ORDER BY _timestamp) FROM "logs"` - SQL Mode is ON **What happens when you click Build:** 1. **Complex pattern detected** (window function) 2. Build tab opens in **Custom SQL Mode** 3. Chart type: **Table** (with dynamic columns) --- ### Scenario 13: Nested Function Query **Starting state:** - Query: `SELECT ceil(count(*)) as rounded_count FROM "logs"` - SQL Mode is ON **What happens when you click Build:** 1. **Complex pattern detected** (nested function - function inside function) 2. Build tab opens in **Custom SQL Mode** 3. Chart type: **Table** (with dynamic columns) --- ### Scenario 14: JSON Operations Query **Starting state:** - Query: `SELECT data->>'name' as name FROM "logs"` - SQL Mode is ON **What happens when you click Build:** 1. **Complex pattern detected** (JSON operator) 2. Build tab opens in **Custom SQL Mode** 3. Chart type: **Table** (with dynamic columns) --- ### Scenario 15: CASE/WHEN Query **Starting state:** - Query: `SELECT CASE WHEN status >= 500 THEN 'error' ELSE 'ok' END as status_type, count(*) FROM "logs" GROUP BY status_type` - SQL Mode is ON **What happens when you click Build:** 1. Query is parsed successfully (CASE/WHEN is supported) 2. Build tab opens in **Builder Mode** 3. Chart type: **Bar** (default) 4. CASE/WHEN shown as "raw" field type --- ### Scenario 16: Multiple JOINs (Flat) **Starting state:** - Query: `SELECT a.field as "field1", b.field as "field2" FROM "a" INNER JOIN "b" AS stream_0 ON a.x = b.x INNER JOIN "c" AS stream_1 ON b.y = c.y GROUP BY field1, field2 ORDER BY field1 ASC` - SQL Mode is ON **What happens when you click Build:** 1. Query is parsed successfully (flat JOINs are supported) 2. Build tab opens in **Builder Mode** 3. Chart type: **Bar** (default) --- ### Scenario 17: Opening Shared Link (First Time) **Starting state:** - URL contains `build_data` parameter with chart type "bar" - Query in URL: `SELECT status, count(*) FROM "logs" GROUP BY status` **What happens:** 1. Build tab opens 2. Query is parsed successfully 3. Chart type: **Bar** (restored from URL - first toggle) 4. Build tab opens in **Builder Mode** --- ### Scenario 18: Switching Back to Build Tab (After First Time) **Starting state:** - Previously visited Build tab - URL still has `build_data` with chart type "bar" - Query: `SELECT count(*) FROM "logs"` (metric query) **What happens:** 1. Build tab opens 2. Query is parsed successfully 3. Chart type: **Metric** (auto-selected, URL ignored on subsequent toggles) --- ### Scenario 19: Invalid SQL Syntax **Starting state:** - Query: `SELEC * FORM "logs"` (typos) - SQL Mode is ON **What happens when you click Build:** 1. **Parse error** (invalid SQL) 2. Build tab opens in **Custom SQL Mode** 3. Chart type: **Table** (with dynamic columns) 4. Query editor is **editable** (so user can fix the query) --- ### Scenario 20: Query with WHERE Filters **Starting state:** - Query: `SELECT histogram(_timestamp), count(*) FROM "logs" WHERE status = 500 AND method = 'GET' GROUP BY 1` - SQL Mode is ON **What happens when you click Build:** 1. Query is parsed successfully 2. Build tab opens in **Builder Mode** 3. Chart type: **Bar** (default) 4. Filters populated in filter section --- ## Summary Table | Query Type | Mode | Chart Type | |------------|------|------------| | SELECT * | **Custom** | **Table** | | Histogram + count | Builder | Bar | | Count only (no GROUP BY) | Builder | Metric | | 1-2 GROUP BY fields | Builder | Bar | | 3+ GROUP BY fields | Builder | Table | | Simple JOIN | Builder | Bar | | CASE/WHEN | Builder | Bar | | Multiple flat JOINs | Builder | Bar | | CTE (WITH clause) | **Custom** | **Table** | | Subquery | **Custom** | **Table** | | Nested JOIN | **Custom** | **Table** | | UNION/INTERSECT/EXCEPT | **Custom** | **Table** | | Window functions | **Custom** | **Table** | | Nested functions | **Custom** | **Table** | | JSON operations | **Custom** | **Table** | | Invalid SQL | **Custom** | **Table** | --- ## Quick Mode Auto-Enable Quick Mode is automatically enabled when switching to Build tab if: | Condition | Quick Mode | |-----------|------------| | SQL Mode was OFF | ✅ Enabled | | Query is `SELECT * FROM ...` | ✅ Enabled | | Complex aggregation query | ❌ Not changed | **Requires:** `quick_mode_enabled = true` in config --- ## SQL Mode Auto-Enable | Starting State | After Clicking Build | |----------------|---------------------| | SQL Mode OFF | SQL Mode **ON** | | SQL Mode ON | SQL Mode ON (no change) | **Build tab always requires SQL Mode to be ON.** --- ## Chart Type Priority When auto-selecting chart type: 1. **Custom Mode?** → Table 2. **3+ GROUP BY fields?** → Table 3. **Only Y-axis (no X, no breakdown)?** → Metric 4. **Everything else** → Bar (default) **Exception:** First visit with shared link uses chart type from URL. --- ## Builder Mode vs Custom Mode | Aspect | Builder Mode | Custom Mode | |--------|--------------|-------------| | Query Editor | Read-only | Editable | | Field Selectors | Visible | Hidden | | When Used | Parseable queries | Complex/unparseable queries | | Default Chart | Bar/Metric | Table | --------- Co-authored-by: github-actions[bot] Co-authored-by: ktx-akshay --- .github/workflows/playwright.yml | 1 + .../pages/dashboardPages/visualise.js | 2 +- tests/ui-testing/pages/logsPages/logsPage.js | 532 ++++++- .../Logs/logsQueryBuilder.spec.js | 381 +++++ .../PanelEditor/PanelEditor.spec.ts | 1210 ++++++++++++++++ .../dashboards/PanelEditor/PanelEditor.vue | 1269 +++++++++++++++++ .../composables/usePanelEditor.spec.ts | 1128 +++++++++++++++ .../PanelEditor/composables/usePanelEditor.ts | 795 +++++++++++ .../dashboards/PanelEditor/index.ts | 78 + .../PanelEditor/types/panelEditor.spec.ts | 441 ++++++ .../PanelEditor/types/panelEditor.ts | 669 +++++++++ .../addPanel/DashboardQueryBuilder.vue | 11 +- .../dashboard/usePanelDataLoader.spec.ts | 98 +- .../dashboard/usePanelDataLoader.ts | 18 +- web/src/composables/useLogs/logsUtils.spec.ts | 763 ++++++++++ web/src/composables/useLogs/logsUtils.ts | 29 +- .../useLogs/logsVisualization.spec.ts | 137 +- .../composables/useLogs/logsVisualization.ts | 59 + web/src/locales/languages/de.json | 16 +- web/src/locales/languages/en.json | 10 + web/src/locales/languages/es.json | 16 +- web/src/locales/languages/fr.json | 16 +- web/src/locales/languages/it.json | 16 +- web/src/locales/languages/ja.json | 14 +- web/src/locales/languages/ko.json | 14 +- web/src/locales/languages/nl.json | 16 +- web/src/locales/languages/pt.json | 16 +- web/src/locales/languages/tr.json | 16 +- web/src/locales/languages/zh.json | 14 +- web/src/plugins/logs/BuildQueryPage.spec.ts | 768 ++++++++++ web/src/plugins/logs/BuildQueryPage.vue | 458 ++++++ web/src/plugins/logs/Index.spec.ts | 125 ++ web/src/plugins/logs/Index.vue | 252 +++- web/src/plugins/logs/SearchBar.spec.ts | 159 +++ web/src/plugins/logs/SearchBar.vue | 196 ++- .../plugins/logs/VisualizeLogsQuery.spec.ts | 169 ++- web/src/plugins/logs/VisualizeLogsQuery.vue | 860 +---------- web/src/plugins/metrics/Index.vue | 1092 ++------------ web/src/styles/logs/logs-page.scss | 13 +- .../utils/dashboard/convertPanelData.spec.ts | 142 +- web/src/utils/dashboard/convertPanelData.ts | 15 + web/src/utils/logs/constants.ts | 1 + web/src/utils/query/sqlQueryParser.spec.ts | 509 +++++++ web/src/utils/query/sqlQueryParser.ts | 446 ++++++ web/src/utils/query/sqlUtils.spec.ts | 176 ++- web/src/utils/query/sqlUtils.ts | 205 ++- .../Dashboards/addPanel/AddPanel.spec.ts | 182 +-- .../views/Dashboards/addPanel/AddPanel.vue | 1180 +-------------- 48 files changed, 11530 insertions(+), 3203 deletions(-) create mode 100644 tests/ui-testing/playwright-tests/Logs/logsQueryBuilder.spec.js create mode 100644 web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts create mode 100644 web/src/components/dashboards/PanelEditor/PanelEditor.vue create mode 100644 web/src/components/dashboards/PanelEditor/composables/usePanelEditor.spec.ts create mode 100644 web/src/components/dashboards/PanelEditor/composables/usePanelEditor.ts create mode 100644 web/src/components/dashboards/PanelEditor/index.ts create mode 100644 web/src/components/dashboards/PanelEditor/types/panelEditor.spec.ts create mode 100644 web/src/components/dashboards/PanelEditor/types/panelEditor.ts create mode 100644 web/src/composables/useLogs/logsUtils.spec.ts create mode 100644 web/src/plugins/logs/BuildQueryPage.spec.ts create mode 100644 web/src/plugins/logs/BuildQueryPage.vue create mode 100644 web/src/utils/query/sqlQueryParser.spec.ts create mode 100644 web/src/utils/query/sqlQueryParser.ts diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index b809cdcea4..195cdb1b85 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -138,6 +138,7 @@ jobs: "logshistogram.spec.js", "logspage.spec.js", "logsquickmode.spec.js", + "logsQueryBuilder.spec.js", "pagination.spec.js", "unflattened.spec.js", "logstable.spec.js", diff --git a/tests/ui-testing/pages/dashboardPages/visualise.js b/tests/ui-testing/pages/dashboardPages/visualise.js index 7e13793f63..826cd8cee8 100644 --- a/tests/ui-testing/pages/dashboardPages/visualise.js +++ b/tests/ui-testing/pages/dashboardPages/visualise.js @@ -206,7 +206,7 @@ export default class LogsVisualise { //stream index list async streamIndexList() { await this.page - .locator('[data-test="dashboard-field-list-collapsed-icon"]') + .locator('[data-test="panel-editor-field-list-collapsed-icon"]') .click(); } //logs: Select stream diff --git a/tests/ui-testing/pages/logsPages/logsPage.js b/tests/ui-testing/pages/logsPages/logsPage.js index 3687ed9759..d16145618b 100644 --- a/tests/ui-testing/pages/logsPages/logsPage.js +++ b/tests/ui-testing/pages/logsPages/logsPage.js @@ -139,6 +139,58 @@ export class LogsPage { this.resultErrorDetailsBtn = '[data-test="logs-page-result-error-details-btn"]'; this.searchDetailErrorMessage = '[data-test="logs-search-detail-error-message"]'; + // ===== BUILD TAB / QUERY BUILDER SELECTORS (PR #10305) ===== + // Tab navigation + this.buildToggle = '[data-test="logs-build-toggle"]'; + this.logsToggle = '[data-test="logs-logs-toggle"]'; + this.visualizeToggle = '[data-test="logs-visualize-toggle"]'; + this.patternsToggle = '[data-test="logs-patterns-toggle"]'; + + // Query type selector (Auto/Custom mode) + this.builderQueryType = '[data-test="dashboard-builder-query-type"]'; + this.customQueryType = '[data-test="dashboard-custom-query-type"]'; + this.sqlQueryType = '[data-test="dashboard-sql-query-type"]'; + this.promqlQueryType = '[data-test="dashboard-promql-query-type"]'; + + // Dashboard query builder axes + this.xAxisLayout = '[data-test="dashboard-x-layout"]'; + this.yAxisLayout = '[data-test="dashboard-y-layout"]'; + this.breakdownLayout = '[data-test="dashboard-b-layout"]'; + this.zAxisLayout = '[data-test="dashboard-z-layout"]'; + this.xAxisItem = (alias) => `[data-test="dashboard-x-item-${alias}"]`; + this.yAxisItem = (alias) => `[data-test="dashboard-y-item-${alias}"]`; + this.breakdownItem = (alias) => `[data-test="dashboard-b-item-${alias}"]`; + this.xAxisItemRemove = (alias) => `[data-test="dashboard-x-item-${alias}-remove"]`; + this.yAxisItemRemove = (alias) => `[data-test="dashboard-y-item-${alias}-remove"]`; + this.breakdownItemRemove = (alias) => `[data-test="dashboard-b-item-${alias}-remove"]`; + + // Field list for builder + this.streamTypeDropdown = '[data-test="index-dropdown-stream_type"]'; + this.streamDropdown = '[data-test="index-dropdown-stream"]'; + this.addToXAxis = '[data-test="dashboard-add-x-data"]'; + this.addToYAxis = '[data-test="dashboard-add-y-data"]'; + this.addToBreakdown = '[data-test="dashboard-add-b-data"]'; + this.addToFilter = '[data-test="dashboard-add-filter-data"]'; + this.fieldListSearchInput = '[data-test="index-field-search-input"]'; + + // Chart selection + this.chartSelectionContainer = '[data-test="dashboard-addpanel-chart-selection-item"]'; + this.chartTypeItem = (chartId) => `[data-test="selected-chart-${chartId}-item"]`; + + // Panel editor + this.fieldListCollapsedIcon = '[data-test="panel-editor-field-list-collapsed-icon"]'; + this.customChartTypeBtn = '[data-test="custom-chart-type-selector-btn"]'; + + // Config panel + this.configShowLegend = '[data-test="dashboard-config-show-legend"]'; + this.configDynamicColumns = '[data-test="dashboard-config-table_dynamic_columns"]'; + this.configLimit = '[data-test="dashboard-config-limit"]'; + + // Chart renderer + this.chartRenderer = '[data-test="chart-renderer"]'; + this.noDataMessage = '[data-test="no-data"]'; + this.dashboardPanelTable = '[data-test="dashboard-panel-table"]'; + // ===== SHARE LINK SELECTORS (VERIFIED) ===== this.shareLinkButton = '[data-test="logs-search-bar-share-link-btn"]'; this.shareLinkTooltip = '[role="tooltip"], .q-tooltip'; @@ -1695,6 +1747,48 @@ export class LogsPage { return await this.page.locator(this.queryButton).click({ force: true }); } + /** + * Click Run query and wait for query execution to complete. + * Uses button UI state (loading/disabled → ready) instead of response matching + * to avoid capturing stale responses from auto-searches. + * @param {number} timeout - Max wait time in ms (default 60000) + */ + async runQueryAndWaitForResults(timeout = 60000) { + const btn = this.page.locator(this.queryButton); + + // Click Run query + await btn.click({ force: true }); + + // Wait for the button to enter loading/disabled state (query started) + await this.page.waitForFunction( + (selector) => { + const btn = document.querySelector(selector); + if (!btn) return false; + return btn.hasAttribute('disabled') || btn.classList.contains('q-btn--loading') || btn.textContent?.trim()?.includes('Cancel'); + }, + this.queryButton, + { timeout: 5000 } + ).catch(() => { + // Query may have completed instantly — that's OK + }); + + // Wait for the button to exit loading/disabled state (query completed) + await this.page.waitForFunction( + (selector) => { + const btn = document.querySelector(selector); + if (!btn) return false; + const isDisabled = btn.hasAttribute('disabled') || btn.getAttribute('aria-disabled') === 'true'; + const isLoading = btn.classList.contains('q-btn--loading'); + const text = btn.textContent?.trim() || ''; + return !isDisabled && !isLoading && !text.includes('Cancel'); + }, + this.queryButton, + { timeout } + ); + + testLogger.info('Query execution complete - Run query button ready'); + } + /** * Ingest multiple log entries with retry logic for "stream being deleted" errors * Uses page.request API to keep credentials in Node.js context (secure) @@ -5650,6 +5744,22 @@ export class LogsPage { testLogger.info('Monaco query editor is visible'); } + /** + * Set query editor content reliably using Playwright's fill() on the Monaco .inputarea. + * This properly clears existing content before typing, unlike Ctrl+A + Backspace which + * can fail to clear Monaco in certain states. + * @param {string} query - The SQL query to set + */ + async setQueryEditorContent(query) { + // Monaco's .inputarea is behind the .view-line overlay, so use force:true to bypass + const inputArea = this.page.locator('[data-test="logs-search-bar-query-editor"] .inputarea'); + await inputArea.click({ force: true }); + await inputArea.fill(query); + // Wait for Monaco to render the new content in the view-line + await this.page.locator('[data-test="logs-search-bar-query-editor"] .view-line').first().waitFor({ state: 'visible', timeout: 5000 }); + testLogger.info(`Query editor set to: "${query.substring(0, 60)}"`); + } + /** * Enable SQL mode if not already enabled * Combines getSQLModeState() check with clickSQLModeSwitch() @@ -5659,7 +5769,8 @@ export class LogsPage { const isChecked = await sqlModeToggle.getAttribute('aria-checked'); if (isChecked !== 'true') { await sqlModeToggle.click(); - await this.page.waitForTimeout(1000); + // Wait for the toggle to actually switch ON + await expect(sqlModeToggle).toHaveAttribute('aria-checked', 'true', { timeout: 5000 }); testLogger.info('SQL mode enabled'); } else { testLogger.info('SQL mode already enabled'); @@ -6003,6 +6114,425 @@ export class LogsPage { testLogger.info(`Pattern details showing pattern ${expectedIndex}`); } + // ============================================================================ + // BUILD TAB / QUERY BUILDER METHODS - PR #10305 + // These methods support the Auto Query Builder feature on the Logs page + // ============================================================================ + + /** + * Click the Build tab toggle to switch to Build mode + */ + async clickBuildToggle() { + await this.page.locator(this.buildToggle).click(); + testLogger.info('Clicked Build tab toggle'); + } + + /** + * Click the Logs tab toggle to switch back to Logs mode + */ + async clickLogsToggle() { + await this.page.locator(this.logsToggle).click(); + await this.page.waitForTimeout(500); + testLogger.info('Clicked Logs tab toggle'); + } + + /** + * Click the Visualize tab toggle + */ + async clickVisualizeToggle() { + await this.page.locator(this.visualizeToggle).click(); + await this.page.waitForTimeout(500); + testLogger.info('Clicked Visualize tab toggle'); + } + + /** + * Expect Build tab toggle to be visible + */ + async expectBuildToggleVisible() { + await expect(this.page.locator(this.buildToggle)).toBeVisible(); + testLogger.info('Build tab toggle is visible'); + } + + /** + * Expect Build tab to be active (check aria-pressed or similar attribute) + */ + async expectBuildTabActive() { + const buildToggle = this.page.locator(this.buildToggle); + await expect(buildToggle).toBeVisible(); + testLogger.info('Build tab is active'); + } + + /** + * Expect Builder mode (Auto mode) to be active + */ + async expectBuilderModeActive() { + const builderTypeBtn = this.page.locator(this.builderQueryType); + await expect(builderTypeBtn).toBeVisible(); + testLogger.info('Builder mode is active'); + } + + /** + * Expect Custom SQL mode to be active + */ + async expectCustomModeActive() { + const customTypeBtn = this.page.locator(this.customQueryType); + await expect(customTypeBtn).toBeVisible(); + testLogger.info('Custom SQL mode is available'); + } + + /** + * Click Builder/Auto query type toggle + */ + async clickBuilderQueryType() { + await this.page.locator(this.builderQueryType).click(); + await this.page.waitForTimeout(500); + testLogger.info('Clicked Builder query type'); + } + + /** + * Click Custom SQL query type toggle + */ + async clickCustomQueryType() { + await this.page.locator(this.customQueryType).click(); + await this.page.waitForTimeout(500); + testLogger.info('Clicked Custom query type'); + } + + /** + * Expect X-axis layout section to be visible + */ + async expectXAxisLayoutVisible() { + await expect(this.page.locator(this.xAxisLayout)).toBeVisible(); + testLogger.info('X-axis layout is visible'); + } + + /** + * Expect Y-axis layout section to be visible + */ + async expectYAxisLayoutVisible() { + await expect(this.page.locator(this.yAxisLayout)).toBeVisible(); + testLogger.info('Y-axis layout is visible'); + } + + /** + * Expect Breakdown layout section to be visible + */ + async expectBreakdownLayoutVisible() { + await expect(this.page.locator(this.breakdownLayout)).toBeVisible(); + testLogger.info('Breakdown layout is visible'); + } + + /** + * Add a field to X-axis (click the add button) + */ + async clickAddToXAxis() { + await this.page.locator(this.addToXAxis).click(); + await this.page.waitForTimeout(500); + testLogger.info('Clicked Add to X-axis'); + } + + /** + * Add a field to Y-axis (click the add button) + */ + async clickAddToYAxis() { + await this.page.locator(this.addToYAxis).click(); + await this.page.waitForTimeout(500); + testLogger.info('Clicked Add to Y-axis'); + } + + /** + * Add a field to Breakdown (click the add button) + */ + async clickAddToBreakdown() { + await this.page.locator(this.addToBreakdown).click(); + await this.page.waitForTimeout(500); + testLogger.info('Clicked Add to Breakdown'); + } + + /** + * Expect a specific X-axis item to be visible + * @param {string} alias - The field alias/name + */ + async expectXAxisItemVisible(alias) { + await expect(this.page.locator(this.xAxisItem(alias))).toBeVisible(); + testLogger.info(`X-axis item "${alias}" is visible`); + } + + /** + * Expect a specific Y-axis item to be visible + * @param {string} alias - The field alias/name + */ + async expectYAxisItemVisible(alias) { + await expect(this.page.locator(this.yAxisItem(alias))).toBeVisible(); + testLogger.info(`Y-axis item "${alias}" is visible`); + } + + /** + * Remove a field from X-axis + * @param {string} alias - The field alias/name + */ + async removeXAxisItem(alias) { + await this.page.locator(this.xAxisItemRemove(alias)).click(); + await this.page.waitForTimeout(500); + testLogger.info(`Removed X-axis item "${alias}"`); + } + + /** + * Remove a field from Y-axis + * @param {string} alias - The field alias/name + */ + async removeYAxisItem(alias) { + await this.page.locator(this.yAxisItemRemove(alias)).click(); + await this.page.waitForTimeout(500); + testLogger.info(`Removed Y-axis item "${alias}"`); + } + + /** + * Select a chart type by its ID + * @param {string} chartId - The chart type ID (e.g., 'bar', 'line', 'metric', 'table') + */ + async selectChartType(chartId) { + // Use .first() to handle multiple matching elements (e.g., from cached panels) + const chartItem = this.page.locator(this.chartTypeItem(chartId)).first(); + + // Click the chart item (tests should check visibility before calling this) + await chartItem.click(); + await this.page.waitForTimeout(500); + testLogger.info(`Selected chart type: ${chartId}`); + } + + /** + * Expect a specific chart type to be visible in the chart selection + * @param {string} chartId - The chart type ID + */ + async expectChartTypeVisible(chartId) { + const chartItem = this.page.locator(this.chartTypeItem(chartId)).first(); + await expect(chartItem).toBeVisible(); + testLogger.info(`Chart type "${chartId}" is visible`); + } + + /** + * Verify a chart type is selected (theme-aware: checks bg-grey-3 for light, bg-grey-5 for dark) + * Uses the parent element's background class to detect selection state. + * @param {string} chartId - The chart type ID (e.g., 'bar', 'line', 'metric', 'table') + * @param {boolean} shouldBeSelected - Whether the chart type should be selected (default: true) + */ + async verifyChartTypeSelected(chartId, shouldBeSelected = true, timeout = 20000) { + // Use visible() filter — there can be multiple PanelEditor instances in DOM + // (e.g., cached Visualize tab + active Build tab). Only check the visible one. + const selector = this.chartTypeItem(chartId); + const parentLocator = this.page.locator(selector).locator('visible=true').locator('..'); + + if (shouldBeSelected) { + await expect(parentLocator).toHaveClass(/bg-grey-[35]/, { timeout }); + testLogger.info(`Chart type "${chartId}" is selected (verified via bg-grey class)`); + } else { + await expect(parentLocator).not.toHaveClass(/bg-grey-[35]/, { timeout: 5000 }); + testLogger.info(`Chart type "${chartId}" is NOT selected (verified via bg-grey class)`); + } + } + + /** + * Expect chart renderer to be visible (chart preview loaded) + */ + async expectChartRendererVisible() { + await expect(this.page.locator(this.chartRenderer)).toBeVisible({ timeout: 30000 }); + testLogger.info('Chart renderer is visible'); + } + + /** + * Expect "No data" message to be visible + */ + async expectNoDataMessageVisible() { + await expect(this.page.locator(this.noDataMessage)).toBeVisible(); + testLogger.info('No data message is visible'); + } + + /** + * Expect dashboard panel table to be visible + */ + async expectDashboardPanelTableVisible() { + await expect(this.page.locator(this.dashboardPanelTable)).toBeVisible({ timeout: 30000 }); + testLogger.info('Dashboard panel table is visible'); + } + + /** + * Check if SQL Mode is currently ON + * @returns {Promise} True if SQL mode is ON + */ + async isSqlModeOn() { + const sqlModeToggle = this.page.getByRole('switch', { name: 'SQL Mode' }); + const isChecked = await sqlModeToggle.getAttribute('aria-checked'); + return isChecked === 'true'; + } + + /** + * Verify SQL Mode auto-enables when switching to Build tab + */ + async verifySqlModeAutoEnablesOnBuild() { + const sqlModeToggle = this.page.getByRole('switch', { name: 'SQL Mode' }); + const isChecked = await sqlModeToggle.getAttribute('aria-checked'); + + if (isChecked === 'true') { + await sqlModeToggle.click(); + await this.page.waitForTimeout(500); + } + + await this.clickBuildToggle(); + await this.page.waitForTimeout(1000); + + const isNowChecked = await sqlModeToggle.getAttribute('aria-checked'); + if (isNowChecked === 'true') { + testLogger.info('SQL Mode auto-enabled on Build tab switch'); + return true; + } else { + testLogger.warn('SQL Mode did NOT auto-enable on Build tab switch'); + return false; + } + } + + /** + * Search for a field in the field list + * @param {string} fieldName - The field name to search + */ + async searchFieldInBuilder(fieldName) { + await this.page.locator(this.fieldListSearchInput).fill(fieldName); + await this.page.waitForTimeout(500); + testLogger.info(`Searched for field: ${fieldName}`); + } + + /** + * Clear field search in builder + */ + async clearFieldSearch() { + await this.page.locator(this.fieldListSearchInput).clear(); + await this.page.waitForTimeout(300); + testLogger.info('Cleared field search'); + } + + /** + * Toggle field list collapse/expand in panel editor + */ + async toggleFieldListCollapse() { + await this.page.locator(this.fieldListCollapsedIcon).click(); + await this.page.waitForTimeout(500); + testLogger.info('Toggled field list collapse'); + } + + /** + * Enter a query in the query editor and verify it's accepted + * @param {string} query - The SQL query to enter + */ + async enterBuildQuery(query) { + await this.clickQueryEditor(); + await this.page.waitForTimeout(300); + await this.selectAllText(); + await this.pressBackspace(); + await this.page.keyboard.type(query); + await this.page.waitForTimeout(500); + testLogger.info(`Entered build query: ${query.substring(0, 50)}...`); + } + + /** + * Wait for Build tab UI to be fully loaded + * @param {number} timeout - Timeout in milliseconds + */ + async waitForBuildTabLoaded(timeout = 30000) { + // Phase 1: Wait for BuildQueryPage root container to be visible + try { + await this.page.locator('.build-query-page').waitFor({ state: 'visible', timeout }); + testLogger.info('Build tab container loaded'); + } catch (error) { + testLogger.warn('Build tab container did not appear within timeout'); + return false; + } + + // Phase 2: Wait for async initializeBuild() to complete. + // initializeBuild parses query, sets chart type, runs query, renders chart/table/no-data. + // Use visible filter to avoid hidden cached PanelEditor instances. + try { + const initIndicator = this.page.locator( + `${this.chartRenderer}, ${this.dashboardPanelTable}, ${this.noDataMessage}` + ).locator('visible=true').first(); + await initIndicator.waitFor({ state: 'visible', timeout }); + testLogger.info('Build tab initialization complete (chart/table/no-data visible)'); + } catch (error) { + // Phase 2 is best-effort — Build tab may not render data for all query types + testLogger.warn('Build tab chart/table/no-data not visible, waiting for networkidle'); + await this.page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); + } + + return true; + } + + /** + * Get the current chart type from the UI (theme-aware) + * Checks parent element for bg-grey-3 (light) or bg-grey-5 (dark) to detect selection. + * @returns {Promise} The current chart type or null + */ + async getCurrentChartType() { + const chartTypes = ['bar', 'line', 'area', 'area-stacked', 'metric', 'table', 'scatter', 'pie', 'donut', 'h-bar', 'h-stacked', 'stacked', 'heatmap', 'gauge']; + + for (const chartType of chartTypes) { + const chartItem = this.page.locator(this.chartTypeItem(chartType)).first(); + const isVisible = await chartItem.isVisible().catch(() => false); + if (isVisible) { + const parentClassList = await chartItem.locator('..').getAttribute('class') || ''; + if (parentClassList.includes('bg-grey-3') || parentClassList.includes('bg-grey-5')) { + testLogger.info(`Current chart type detected: ${chartType}`); + return chartType; + } + } + } + testLogger.warn('No chart type detected as selected'); + return null; + } + + /** + * Wait for any chart type to become selected (theme-aware). + * Uses page.waitForFunction for reliable detection of bg-grey-3/bg-grey-5 + * directly in the DOM, surviving reactive re-renders across tab switches. + * @param {number} timeout - Max wait time in ms (default 20000) + * @returns {Promise} The selected chart type or null if timeout + */ + async waitForChartTypeStabilized(timeout = 20000) { + try { + // Use waitForFunction to detect bg-grey-3 or bg-grey-5 on chart selection items + // This is more reliable than Playwright locator polling during reactive re-renders + const result = await this.page.waitForFunction(() => { + const items = document.querySelectorAll('[data-test="dashboard-addpanel-chart-selection-item"]'); + for (const item of items) { + const classes = item.className || ''; + if (classes.includes('bg-grey-3') || classes.includes('bg-grey-5')) { + // Found selected item - extract chart type from child data-test attribute + const section = item.querySelector('[data-test^="selected-chart-"][data-test$="-item"]'); + if (section) { + const attr = section.getAttribute('data-test'); + const match = attr.match(/^selected-chart-(.+)-item$/); + if (match) return match[1]; + } + } + } + return null; + }, { timeout }); + + const chartType = await result.jsonValue(); + if (chartType) { + testLogger.info(`Chart type stabilized: ${chartType}`); + return chartType; + } + } catch (error) { + testLogger.warn(`Chart type did not stabilize within ${timeout}ms`); + } + + // Fallback: try getCurrentChartType one last time + const fallback = await this.getCurrentChartType(); + if (fallback) { + testLogger.info(`Chart type detected via fallback: ${fallback}`); + } + return fallback; + } + // ============================================================================ // V0.40 REGRESSION TEST METHODS // VRL fields, Query Inspector, Sorting, and Highlight tests diff --git a/tests/ui-testing/playwright-tests/Logs/logsQueryBuilder.spec.js b/tests/ui-testing/playwright-tests/Logs/logsQueryBuilder.spec.js new file mode 100644 index 0000000000..b2388eb219 --- /dev/null +++ b/tests/ui-testing/playwright-tests/Logs/logsQueryBuilder.spec.js @@ -0,0 +1,381 @@ +/** + * Logs Query Builder E2E Tests + * + * Feature: Auto Query Builder on Logs Page (PR #10305) + * Tests the Build tab functionality including: + * - Tab navigation (Logs <-> Build) + * - Builder Mode (for parseable queries) + * - Custom SQL Mode (for complex queries) + * - Chart type selection and auto-selection + * - X/Y axis and breakdown field manipulation + * + * Chart type auto-selection logic (from BuildQueryPage.vue): + * - Histogram (X+Y axes) → "bar" (default, not overridden) + * - Count-only (Y only) → "metric" (auto-selected) + * - CTE / Subquery → "table" (custom mode) + * - SELECT * → "table" (custom mode) + * + * @tags @queryBuilder @logs @all + */ + +const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); +const testLogger = require('../utils/test-logger.js'); +const PageManager = require('../../pages/page-manager.js'); +const logData = require('../../fixtures/log.json'); +const logsdata = require('../../../test-data/logs_data.json'); + +// ============================================================================ +// Utility Functions +// ============================================================================ + +async function ingestTestData(page) { + const orgId = process.env["ORGNAME"]; + const streamName = "e2e_automate"; + const basicAuthCredentials = Buffer.from( + `${process.env["ZO_ROOT_USER_EMAIL"]}:${process.env["ZO_ROOT_USER_PASSWORD"]}` + ).toString('base64'); + + const headers = { + "Authorization": `Basic ${basicAuthCredentials}`, + "Content-Type": "application/json", + }; + + const response = await page.evaluate(async ({ url, headers, orgId, streamName, logsdata }) => { + const fetchResponse = await fetch(`${url}/api/${orgId}/${streamName}/_json`, { + method: 'POST', + headers: headers, + body: JSON.stringify(logsdata) + }); + return await fetchResponse.json(); + }, { + url: process.env.INGESTION_URL, + headers: headers, + orgId: orgId, + streamName: streamName, + logsdata: logsdata + }); + + testLogger.debug('API response received', { response }); +} + +async function applyQueryButton(page) { + const searchPromise = page.waitForResponse( + response => response.url().includes('/_search') && response.status() === 200, + { timeout: 60000 } + ); + + await page.waitForLoadState('networkidle'); + await page.locator("[data-test='logs-search-bar-refresh-btn']").click({ force: true }); + + try { + await searchPromise; + testLogger.info('Search query completed successfully'); + } catch (error) { + testLogger.warn('Search response wait timed out, continuing test'); + await page.waitForLoadState('networkidle', { timeout: 30000 }); + } +} + +/** + * Helper: enable SQL mode, set query, run it, switch to Build tab. + * Waits for Build tab async initialization to complete (chart/table rendered). + */ +async function setupQueryAndSwitchToBuild(pm, page, query) { + await pm.logsPage.enableSqlModeIfNeeded(); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + await pm.logsPage.setQueryEditorContent(query); + await pm.logsPage.runQueryAndWaitForResults(); + await pm.logsPage.clickBuildToggle(); + await pm.logsPage.waitForBuildTabLoaded(); +} + +// ============================================================================ +// Test Suite: P0 - Critical Tests (Smoke) +// ============================================================================ + +test.describe("Logs Query Builder - P0 Critical Tests", () => { + test.describe.configure({ mode: 'serial' }); + let pm; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + + await page.waitForLoadState('networkidle'); + await ingestTestData(page); + await page.waitForLoadState('domcontentloaded'); + + await page.goto(`${logData.logsUrl}?org_identifier=${process.env["ORGNAME"]}`); + await pm.logsPage.selectStream("e2e_automate"); + await applyQueryButton(page); + await page.waitForLoadState('networkidle'); + + testLogger.info('Query Builder test setup completed'); + }); + + test("Build tab opens successfully", { + tag: ['@queryBuilder', '@smoke', '@P0', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing Build tab opens successfully'); + + await pm.logsPage.expectBuildToggleVisible(); + await pm.logsPage.clickBuildToggle(); + + const loaded = await pm.logsPage.waitForBuildTabLoaded(); + expect(loaded).toBeTruthy(); + + testLogger.info('Build tab opens successfully - PASSED'); + }); + + test("Histogram query selects bar chart on Build tab", { + tag: ['@queryBuilder', '@smoke', '@P0', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing histogram query → bar chart'); + + const histogramQuery = 'SELECT histogram(_timestamp) as time, count(*) as count FROM "e2e_automate" GROUP BY time'; + await setupQueryAndSwitchToBuild(pm, page, histogramQuery); + + // Histogram: X-axis + Y-axis → "bar" chart (default, not overridden) + await pm.logsPage.verifyChartTypeSelected('bar'); + + testLogger.info('Histogram query → bar chart - PASSED'); + }); + + test("CTE query selects table chart on Build tab", { + tag: ['@queryBuilder', '@smoke', '@P0', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing CTE query → table chart'); + + const cteQuery = 'WITH recent AS (SELECT * FROM "e2e_automate" LIMIT 10) SELECT * FROM recent'; + await setupQueryAndSwitchToBuild(pm, page, cteQuery); + + // CTE is unparseable → Custom SQL mode → "table" chart + await pm.logsPage.verifyChartTypeSelected('table'); + + testLogger.info('CTE query → table chart - PASSED'); + }); + + test("SELECT star query selects table chart on Build tab", { + tag: ['@queryBuilder', '@smoke', '@P0', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing SELECT * query → table chart'); + + const selectAllQuery = 'SELECT * FROM "e2e_automate" LIMIT 10'; + await setupQueryAndSwitchToBuild(pm, page, selectAllQuery); + + // SELECT * produces empty fields → custom mode → "table" chart + await pm.logsPage.verifyChartTypeSelected('table'); + + testLogger.info('SELECT * query → table chart - PASSED'); + }); + + test("Simple SELECT column query selects table chart on Build tab", { + tag: ['@queryBuilder', '@smoke', '@P0', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing SELECT _timestamp query → table chart'); + + const simpleQuery = 'SELECT _timestamp FROM "e2e_automate"'; + await setupQueryAndSwitchToBuild(pm, page, simpleQuery); + + // Simple column select without aggregation → custom mode → "table" chart + await pm.logsPage.verifyChartTypeSelected('table'); + + testLogger.info('SELECT _timestamp query → table chart - PASSED'); + }); +}); + +// ============================================================================ +// Test Suite: P1 - Tab Navigation Tests +// ============================================================================ + +test.describe("Logs Query Builder - Tab Navigation", () => { + test.describe.configure({ mode: 'serial' }); + let pm; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + + await page.waitForLoadState('networkidle'); + await ingestTestData(page); + await page.waitForLoadState('domcontentloaded'); + + await page.goto(`${logData.logsUrl}?org_identifier=${process.env["ORGNAME"]}`); + await pm.logsPage.selectStream("e2e_automate"); + await applyQueryButton(page); + await page.waitForLoadState('networkidle'); + + testLogger.info('Tab Navigation test setup completed'); + }); + + test("Switch from Logs to Build tab", { + tag: ['@queryBuilder', '@functional', '@P1', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing switch from Logs to Build tab'); + + await pm.logsPage.expectBuildToggleVisible(); + await pm.logsPage.clickBuildToggle(); + await pm.logsPage.waitForBuildTabLoaded(); + await pm.logsPage.expectBuildTabActive(); + + testLogger.info('Switch from Logs to Build tab - PASSED'); + }); + + test("Switch from Build to Logs tab", { + tag: ['@queryBuilder', '@functional', '@P1', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing switch from Build to Logs tab'); + + await pm.logsPage.clickBuildToggle(); + await pm.logsPage.waitForBuildTabLoaded(); + await pm.logsPage.clickLogsToggle(); + await page.waitForLoadState('networkidle'); + await pm.logsPage.expectLogsTableVisible(); + + testLogger.info('Switch from Build to Logs tab - PASSED'); + }); + + test("SQL Mode auto-enables on Build tab", { + tag: ['@queryBuilder', '@functional', '@P1', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing SQL Mode auto-enables on Build tab'); + + const sqlModeToggle = page.getByRole('switch', { name: 'SQL Mode' }); + const isChecked = await sqlModeToggle.getAttribute('aria-checked'); + + if (isChecked === 'true') { + await sqlModeToggle.click(); + await expect(sqlModeToggle).toHaveAttribute('aria-checked', 'false', { timeout: 5000 }); + } + + expect(await sqlModeToggle.getAttribute('aria-checked')).toBe('false'); + + await pm.logsPage.clickBuildToggle(); + await pm.logsPage.waitForBuildTabLoaded(); + + await expect(sqlModeToggle).toHaveAttribute('aria-checked', 'true', { timeout: 5000 }); + + testLogger.info('SQL Mode auto-enables on Build tab - PASSED'); + }); + + test("Build tab preserves query state on tab switch", { + tag: ['@queryBuilder', '@functional', '@P1', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing Build tab preserves query state'); + + await pm.logsPage.enableSqlModeIfNeeded(); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); + const testQuery = 'SELECT count(*) as total FROM "e2e_automate"'; + await pm.logsPage.setQueryEditorContent(testQuery); + + await pm.logsPage.clickBuildToggle(); + await pm.logsPage.waitForBuildTabLoaded(); + + await pm.logsPage.clickLogsToggle(); + await page.waitForLoadState('networkidle'); + + const queryText = await pm.logsPage.getQueryEditorText(); + expect(queryText.toLowerCase()).toContain('e2e_automate'); + const hasAggregation = queryText.toLowerCase().includes('count') || + queryText.toLowerCase().includes('total'); + expect(hasAggregation).toBe(true); + + testLogger.info('Build tab preserves query state - PASSED'); + }); +}); + +// ============================================================================ +// Test Suite: P1 - Chart Type Verification on Tab Switch +// ============================================================================ + +test.describe("Logs Query Builder - Chart Type on Tab Switch", () => { + test.describe.configure({ mode: 'serial' }); + let pm; + + test.beforeEach(async ({ page }, testInfo) => { + testLogger.testStart(testInfo.title, testInfo.file); + await navigateToBase(page); + pm = new PageManager(page); + + await page.waitForLoadState('networkidle'); + await ingestTestData(page); + await page.waitForLoadState('domcontentloaded'); + + await page.goto(`${logData.logsUrl}?org_identifier=${process.env["ORGNAME"]}`); + await pm.logsPage.selectStream("e2e_automate"); + await applyQueryButton(page); + await page.waitForLoadState('networkidle'); + + testLogger.info('Chart Type on Tab Switch test setup completed'); + }); + + test("Histogram query selects bar chart type", { + tag: ['@queryBuilder', '@functional', '@P1', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing histogram → bar chart'); + + const histogramQuery = 'SELECT histogram(_timestamp) as time, count(*) as count FROM "e2e_automate" GROUP BY time'; + await setupQueryAndSwitchToBuild(pm, page, histogramQuery); + + await pm.logsPage.verifyChartTypeSelected('bar'); + + testLogger.info('Histogram → bar chart - PASSED'); + }); + + test("Count-only query selects metric chart type", { + tag: ['@queryBuilder', '@functional', '@P1', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing count-only → metric chart'); + + const countQuery = 'SELECT count(*) as total FROM "e2e_automate"'; + await setupQueryAndSwitchToBuild(pm, page, countQuery); + + // Count-only: Y-axis only (count), no X-axis, no breakdown → "metric" + await pm.logsPage.verifyChartTypeSelected('metric'); + + testLogger.info('Count-only → metric chart - PASSED'); + }); + + test("CTE query selects table chart type", { + tag: ['@queryBuilder', '@functional', '@P1', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing CTE → table chart'); + + const cteQuery = 'WITH recent AS (SELECT * FROM "e2e_automate" LIMIT 10) SELECT * FROM recent'; + await setupQueryAndSwitchToBuild(pm, page, cteQuery); + + // CTE is unparseable → custom mode → "table" + await pm.logsPage.verifyChartTypeSelected('table'); + + testLogger.info('CTE → table chart - PASSED'); + }); + + test("SELECT star query selects table chart type", { + tag: ['@queryBuilder', '@functional', '@P1', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing SELECT * → table chart'); + + const selectAllQuery = 'SELECT * FROM "e2e_automate" LIMIT 10'; + await setupQueryAndSwitchToBuild(pm, page, selectAllQuery); + + await pm.logsPage.verifyChartTypeSelected('table'); + + testLogger.info('SELECT * → table chart - PASSED'); + }); + + test("Subquery selects table chart type", { + tag: ['@queryBuilder', '@functional', '@P1', '@all', '@logs'] + }, async ({ page }) => { + testLogger.info('Testing subquery → table chart'); + + const subquery = 'SELECT * FROM (SELECT code, count(*) as cnt FROM "e2e_automate" GROUP BY code) subq LIMIT 10'; + await setupQueryAndSwitchToBuild(pm, page, subquery); + + // Subquery is unparseable → custom mode → "table" + await pm.logsPage.verifyChartTypeSelected('table'); + + testLogger.info('Subquery → table chart - PASSED'); + }); +}); diff --git a/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts b/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts new file mode 100644 index 0000000000..0a675ea18b --- /dev/null +++ b/web/src/components/dashboards/PanelEditor/PanelEditor.spec.ts @@ -0,0 +1,1210 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mount, shallowMount } from "@vue/test-utils"; +import { ref, reactive, nextTick } from "vue"; +import { createI18n } from "vue-i18n"; +import { Quasar } from "quasar"; +import PanelEditor from "./PanelEditor.vue"; + +// Mock vuex store +const mockStore = { + state: { + timezone: "UTC", + theme: "light", + selectedOrganization: { + identifier: "test-org", + }, + organizationData: { + isDataIngested: true, + }, + zoConfig: { + timestamp_column: "_timestamp", + default_functions: [], + }, + savedViewDialog: { + show: false, + }, + }, + commit: vi.fn(), + dispatch: vi.fn().mockResolvedValue({}), + getters: {}, +}; + +vi.mock("vuex", () => ({ + useStore: () => mockStore, +})); + +// Mock useDashboardPanelData +const mockDashboardPanelData = reactive({ + data: { + description: "", + type: "line", + config: {}, + queries: [ + { + query: "SELECT * FROM logs", + customQuery: false, + fields: { + stream: "logs", + stream_type: "logs", + x: [], + y: [], + z: [], + breakdown: [], + filter: { conditions: [] }, + }, + }, + ], + }, + layout: { + splitter: 20, + showFieldList: true, + showQueryBar: true, + querySplitter: 50, + currentQueryIndex: 0, + isConfigPanelOpen: false, + }, + meta: { + dateTime: { + start_time: new Date("2024-01-01"), + end_time: new Date("2024-01-02"), + }, + stream: { + customQueryFields: [], + vrlFunctionFieldList: [], + }, + streamFields: { + groupedFields: [], + }, + }, +}); + +vi.mock("@/composables/useDashboardPanel", () => ({ + default: () => ({ + dashboardPanelData: mockDashboardPanelData, + resetAggregationFunction: vi.fn(), + resetDashboardPanelData: vi.fn(), + updateGroupedFields: vi.fn(), + }), +})); + +// Mock usePanelEditor composable +vi.mock("./composables/usePanelEditor", () => ({ + usePanelEditor: vi.fn(() => ({ + chartData: ref(undefined), + errorData: reactive({ errors: [] }), + metaData: ref(null), + seriesData: ref([]), + lastTriggeredAt: ref(null), + showLegendsDialog: ref(false), + shouldRefreshWithoutCache: ref(false), + maxQueryRangeWarning: ref(""), + limitNumberOfSeriesWarningMessage: ref(""), + errorMessage: ref(""), + isPartialData: ref(false), + isPanelLoading: ref(false), + isCachedDataDifferWithCurrentTimeRange: ref(false), + searchRequestTraceIds: ref([]), + panelSchemaRendererRef: ref(null), + splitterModel: ref(50), + isOutDated: ref(false), + isLoading: ref(false), + currentPanelData: ref({}), + initChartData: vi.fn(), + runQuery: vi.fn(), + handleChartApiError: vi.fn(), + handleLastTriggeredAtUpdate: vi.fn(), + handleLimitNumberOfSeriesWarningMessage: vi.fn(), + handleIsPartialDataUpdate: vi.fn(), + handleLoadingStateChange: vi.fn(), + handleIsCachedDataDifferWithCurrentTimeRangeUpdate: vi.fn(), + handleResultMetadataUpdate: vi.fn(), + metaDataValue: vi.fn(), + seriesDataUpdate: vi.fn(), + collapseFieldList: vi.fn(), + layoutSplitterUpdated: vi.fn(), + updateVrlFunctionFieldList: vi.fn(), + onDataZoom: vi.fn().mockReturnValue({ start: new Date(), end: new Date() }), + cancelRunningQuery: vi.fn(), + resetErrors: vi.fn(), + updateDateTime: vi.fn(), + })), +})); + +// Mock utility functions +vi.mock("./types/panelEditor", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + resolveConfig: vi.fn().mockReturnValue({ + showQueryEditor: true, + showQueryBuilder: true, + showVariablesSelector: false, + showLastRefreshedTime: true, + showOutdatedWarning: true, + showAddToDashboardButton: false, + showQueryTypeSelector: false, + showGeneratedQueryDisplay: false, + hideChartPreview: false, + }), + }; +}); + +// Mock child components +vi.mock("@/components/dashboards/addPanel/ChartSelection.vue", () => ({ + default: { + name: "ChartSelection", + template: '
ChartSelection
', + }, +})); + +vi.mock("@/components/dashboards/addPanel/FieldList.vue", () => ({ + default: { + name: "FieldList", + template: '
FieldList
', + }, +})); + +vi.mock("@/components/dashboards/addPanel/PanelSidebar.vue", () => ({ + default: { + name: "PanelSidebar", + template: '
', + }, +})); + +vi.mock("@/components/dashboards/addPanel/DashboardQueryBuilder.vue", () => ({ + default: { + name: "DashboardQueryBuilder", + template: '
DashboardQueryBuilder
', + }, +})); + +vi.mock("@/components/dashboards/addPanel/DashboardErrors.vue", () => ({ + default: { + name: "DashboardErrorsComponent", + template: '
DashboardErrors
', + }, +})); + +vi.mock("@/components/dashboards/PanelSchemaRenderer.vue", () => ({ + default: { + name: "PanelSchemaRenderer", + template: '
PanelSchemaRenderer
', + }, +})); + +vi.mock("@/components/common/RelativeTime.vue", () => ({ + default: { + name: "RelativeTime", + template: 'RelativeTime', + }, +})); + +// Create i18n instance +const i18n = createI18n({ + legacy: false, + locale: "en", + messages: { + en: { + panel: { + fields: "Fields", + generatedSql: "Generated SQL", + customSql: "Custom SQL", + }, + dashboard: { + configLabel: "Config", + }, + search: { + addToDashboard: "Add to Dashboard", + }, + }, + }, +}); + +describe("PanelEditor.vue", () => { + let wrapper: any; + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + vi.clearAllMocks(); + }); + + describe("Component Initialization", () => { + it("should mount successfully with dashboard pageType", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + editMode: false, + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.find(".panel-editor").exists()).toBe(true); + }); + + it("should mount successfully with metrics pageType", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "metrics", + editMode: false, + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.exists()).toBe(true); + }); + + it("should mount successfully with logs pageType", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "logs", + editMode: false, + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.exists()).toBe(true); + }); + + it("should mount successfully with build pageType", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "build", + editMode: true, + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.exists()).toBe(true); + }); + }); + + describe("Props Handling", () => { + it("should accept pageType prop", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.props("pageType")).toBe("dashboard"); + }); + + it("should accept editMode prop", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + editMode: true, + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.props("editMode")).toBe(true); + }); + + it("should accept allowedChartTypes prop", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "logs", + allowedChartTypes: ["table", "bar"], + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.props("allowedChartTypes")).toEqual(["table", "bar"]); + }); + + it("should accept selectedDateTime prop", () => { + const dateTime = { + start_time: new Date("2024-01-01"), + end_time: new Date("2024-01-02"), + }; + + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + selectedDateTime: dateTime, + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.props("selectedDateTime")).toEqual(dateTime); + }); + }); + + describe("Exposed Methods", () => { + it("should expose runQuery method", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(typeof wrapper.vm.runQuery).toBe("function"); + }); + + it("should expose resetErrors method", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(typeof wrapper.vm.resetErrors).toBe("function"); + }); + + it("should expose collapseFieldList method", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(typeof wrapper.vm.collapseFieldList).toBe("function"); + }); + + it("should expose updateDateTime method", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(typeof wrapper.vm.updateDateTime).toBe("function"); + }); + + it("should expose dashboardPanelData", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.vm.dashboardPanelData).toBeDefined(); + }); + }); + + describe("Emits", () => { + it("should emit addToDashboard event when triggered", async () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "metrics", + showAddToDashboardButton: true, + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + // Trigger the emit through component's exposed method or internal logic + // The emit would normally be triggered by clicking "Add to Dashboard" button + expect(wrapper.emitted()).toBeDefined(); + }); + + it("should emit customQueryModeChanged on mount (immediate watcher)", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + // The watcher with { immediate: true } should fire on mount + expect(wrapper.emitted("customQueryModeChanged")).toBeDefined(); + }); + + it("should emit searchRequestTraceIdsUpdated on mount (immediate watcher)", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.emitted("searchRequestTraceIdsUpdated")).toBeDefined(); + }); + }); + + describe("Exposed State Refs", () => { + it("should expose chartData ref", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + // chartData starts as undefined + expect(wrapper.vm.chartData).toBeUndefined(); + }); + + it("should expose errorData ref", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.vm.errorData).toBeDefined(); + expect(wrapper.vm.errorData.errors).toEqual([]); + }); + + it("should expose metaData ref", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.vm.metaData).toBeNull(); + }); + + it("should expose seriesData ref", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "metrics", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.vm.seriesData).toEqual([]); + }); + + it("should expose lastTriggeredAt ref", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.vm.lastTriggeredAt).toBeNull(); + }); + + it("should expose isOutDated computed", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.vm.isOutDated).toBe(false); + }); + + it("should expose isLoading computed", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "logs", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.vm.isLoading).toBe(false); + }); + + it("should expose searchRequestTraceIds ref", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.vm.searchRequestTraceIds).toEqual([]); + }); + + it("should expose warning message refs", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.vm.maxQueryRangeWarning).toBe(""); + expect(wrapper.vm.limitNumberOfSeriesWarningMessage).toBe(""); + expect(wrapper.vm.errorMessage).toBe(""); + }); + }); + + describe("Exposed Methods", () => { + it("should expose cancelRunningQuery method", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(typeof wrapper.vm.cancelRunningQuery).toBe("function"); + }); + + it("should expose initChartData method", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(typeof wrapper.vm.initChartData).toBe("function"); + }); + }); + + describe("Content Height Calculation", () => { + it("should calculate correct content height for dashboard pageType", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + // The component should be mounted without errors + expect(wrapper.exists()).toBe(true); + }); + + it("should calculate correct content height for logs pageType", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "logs", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + // The component should be mounted without errors + expect(wrapper.exists()).toBe(true); + }); + + it("should calculate correct content height for metrics pageType", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "metrics", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.exists()).toBe(true); + }); + + it("should calculate correct content height for build pageType", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "build", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.exists()).toBe(true); + }); + }); + + describe("Conditional Rendering", () => { + it("should show collapsed field list when showFieldList is false", () => { + mockDashboardPanelData.layout.showFieldList = false; + + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.find(".field-list-sidebar-header-collapsed").exists()).toBe(true); + + // Restore + mockDashboardPanelData.layout.showFieldList = true; + }); + + it("should not show collapsed field list when showFieldList is true", () => { + mockDashboardPanelData.layout.showFieldList = true; + + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.find(".field-list-sidebar-header-collapsed").exists()).toBe(false); + }); + + it("should show HTML editor section when type is html", async () => { + mockDashboardPanelData.data.type = "html"; + + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + await nextTick(); + expect(wrapper.exists()).toBe(true); + + // Restore + mockDashboardPanelData.data.type = "line"; + }); + + it("should show markdown editor section when type is markdown", async () => { + mockDashboardPanelData.data.type = "markdown"; + + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + await nextTick(); + expect(wrapper.exists()).toBe(true); + + // Restore + mockDashboardPanelData.data.type = "line"; + }); + + it("should show custom chart editor section when type is custom_chart", async () => { + mockDashboardPanelData.data.type = "custom_chart"; + + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + await nextTick(); + expect(wrapper.exists()).toBe(true); + + // Restore + mockDashboardPanelData.data.type = "line"; + }); + }); + + describe("Props Defaults", () => { + it("should default editMode to false", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "dashboard", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.props("editMode")).toBe(false); + }); + + it("should default isUiHistogram to false", () => { + wrapper = shallowMount(PanelEditor, { + props: { + pageType: "logs", + }, + global: { + plugins: [i18n, Quasar], + stubs: { + QSeparator: true, + QSplitter: { + template: '
', + }, + QIcon: true, + QBtn: true, + QTooltip: true, + QAvatar: true, + QDialog: true, + }, + }, + }); + + expect(wrapper.props("isUiHistogram")).toBe(false); + }); + }); +}); diff --git a/web/src/components/dashboards/PanelEditor/PanelEditor.vue b/web/src/components/dashboards/PanelEditor/PanelEditor.vue new file mode 100644 index 0000000000..40fe4f92dd --- /dev/null +++ b/web/src/components/dashboards/PanelEditor/PanelEditor.vue @@ -0,0 +1,1269 @@ + + + + + + + diff --git a/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.spec.ts b/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.spec.ts new file mode 100644 index 0000000000..3c957ae03f --- /dev/null +++ b/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.spec.ts @@ -0,0 +1,1128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ref, reactive, nextTick } from "vue"; +import { usePanelEditor } from "./usePanelEditor"; + +// Mock vuex store +vi.mock("vuex", () => ({ + useStore: () => ({ + state: { + timezone: "UTC", + theme: "light", + }, + commit: vi.fn(), + }), +})); + +// Mock utility functions +vi.mock("@/utils/dashboard/checkConfigChangeApiCall", () => ({ + checkIfConfigChangeRequiredApiCallOrNot: vi.fn().mockReturnValue(false), +})); + +vi.mock("@/utils/zincutils", () => ({ + processQueryMetadataErrors: vi.fn().mockReturnValue(""), +})); + +vi.mock("@/composables/dashboard/useCancelQuery", () => ({ + default: () => ({ + traceIdRef: ref([]), + cancelQuery: vi.fn(), + }), +})); + +describe("usePanelEditor", () => { + let dashboardPanelData: any; + let options: any; + + beforeEach(() => { + // Reset dashboard panel data before each test + dashboardPanelData = reactive({ + data: { + description: "", + type: "line", + config: { + unit: null, + unit_custom: null, + }, + queries: [ + { + query: "SELECT * FROM logs", + customQuery: false, + fields: { + stream: "logs", + stream_type: "logs", + x: [], + y: [], + z: [], + breakdown: [], + filter: { + conditions: [], + }, + }, + }, + ], + htmlContent: "", + markdownContent: "", + customChartContent: "", + }, + layout: { + splitter: 20, + showFieldList: true, + showQueryBar: true, + querySplitter: 50, + currentQueryIndex: 0, + isConfigPanelOpen: false, + }, + meta: { + dateTime: { + start_time: new Date("2024-01-01T00:00:00Z"), + end_time: new Date("2024-01-02T00:00:00Z"), + }, + stream: { + customQueryFields: [], + vrlFunctionFieldList: [], + }, + streamFields: { + groupedFields: [], + }, + }, + }); + + options = { + pageType: "dashboard" as const, + config: { + showQueryEditor: true, + showQueryBuilder: true, + showVariablesSelector: true, + showLastRefreshedTime: true, + showOutdatedWarning: true, + showAddToDashboardButton: false, + showQueryTypeSelector: false, + showGeneratedQueryDisplay: false, + hideChartPreview: false, + }, + dashboardPanelData, + editMode: ref(false), + externalChartData: undefined, + variablesData: undefined, + updatedVariablesData: undefined, + updateCommittedVariables: undefined, + dateTimePickerRef: undefined, + selectedDate: undefined, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("collapseFieldList", () => { + it("should collapse field list when it is currently shown", () => { + const { collapseFieldList } = usePanelEditor(options); + + expect(dashboardPanelData.layout.showFieldList).toBe(true); + expect(dashboardPanelData.layout.splitter).toBe(20); + + collapseFieldList(); + + expect(dashboardPanelData.layout.showFieldList).toBe(false); + expect(dashboardPanelData.layout.splitter).toBe(0); + }); + + it("should expand field list when it is currently hidden", () => { + dashboardPanelData.layout.showFieldList = false; + dashboardPanelData.layout.splitter = 0; + + const { collapseFieldList } = usePanelEditor(options); + + collapseFieldList(); + + expect(dashboardPanelData.layout.showFieldList).toBe(true); + expect(dashboardPanelData.layout.splitter).toBe(20); + }); + + it("should toggle field list visibility on multiple calls", () => { + const { collapseFieldList } = usePanelEditor(options); + + // Initial state: expanded + expect(dashboardPanelData.layout.showFieldList).toBe(true); + + // First call: collapse + collapseFieldList(); + expect(dashboardPanelData.layout.showFieldList).toBe(false); + + // Second call: expand + collapseFieldList(); + expect(dashboardPanelData.layout.showFieldList).toBe(true); + + // Third call: collapse again + collapseFieldList(); + expect(dashboardPanelData.layout.showFieldList).toBe(false); + }); + }); + + describe("layoutSplitterUpdated", () => { + it("should dispatch resize event", () => { + const dispatchEventSpy = vi.spyOn(window, "dispatchEvent"); + + const { layoutSplitterUpdated } = usePanelEditor(options); + layoutSplitterUpdated(); + + expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(Event)); + expect(dispatchEventSpy.mock.calls[0][0].type).toBe("resize"); + }); + }); + + describe("querySplitterUpdated", () => { + it("should dispatch resize event", () => { + const dispatchEventSpy = vi.spyOn(window, "dispatchEvent"); + + const { querySplitterUpdated } = usePanelEditor(options); + querySplitterUpdated(60); + + expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(Event)); + expect(dispatchEventSpy.mock.calls[0][0].type).toBe("resize"); + }); + + it("should update expandedSplitterHeight when query bar is shown", () => { + dashboardPanelData.layout.showQueryBar = true; + + const { querySplitterUpdated, expandedSplitterHeight } = usePanelEditor(options); + querySplitterUpdated(75); + + expect(expandedSplitterHeight.value).toBe(75); + }); + + it("should not update expandedSplitterHeight when query bar is hidden", () => { + dashboardPanelData.layout.showQueryBar = false; + + const { querySplitterUpdated, expandedSplitterHeight } = usePanelEditor(options); + querySplitterUpdated(75); + + expect(expandedSplitterHeight.value).toBeNull(); + }); + }); + + describe("seriesDataUpdate", () => { + it("should update seriesData with provided data", () => { + const { seriesDataUpdate, seriesData } = usePanelEditor(options); + + const testData = [ + { name: "series1", data: [1, 2, 3] }, + { name: "series2", data: [4, 5, 6] }, + ]; + + seriesDataUpdate(testData); + + expect(seriesData.value).toEqual(testData); + }); + + it("should handle empty array", () => { + const { seriesDataUpdate, seriesData } = usePanelEditor(options); + + seriesDataUpdate([]); + + expect(seriesData.value).toEqual([]); + }); + + it("should replace previous data", () => { + const { seriesDataUpdate, seriesData } = usePanelEditor(options); + + seriesDataUpdate([{ name: "old" }]); + seriesDataUpdate([{ name: "new" }]); + + expect(seriesData.value).toEqual([{ name: "new" }]); + }); + + it("should handle complex seriesData structures", () => { + const { seriesDataUpdate, seriesData } = usePanelEditor(options); + + const complexData = [ + { + name: "CPU Usage", + type: "line", + data: [ + [1704067200000, 45.5], + [1704070800000, 52.3], + [1704074400000, 48.1], + ], + stack: "total", + areaStyle: { opacity: 0.5 }, + itemStyle: { color: "#5470c6" }, + }, + { + name: "Memory Usage", + type: "bar", + data: [ + [1704067200000, 78.2], + [1704070800000, 82.1], + [1704074400000, 79.5], + ], + barWidth: "60%", + label: { show: true, position: "top" }, + }, + { + name: "Network I/O", + type: "scatter", + data: [ + { value: [100, 200], symbolSize: 10 }, + { value: [150, 250], symbolSize: 15 }, + ], + }, + ]; + + seriesDataUpdate(complexData); + + expect(seriesData.value).toEqual(complexData); + expect(seriesData.value[0].areaStyle).toEqual({ opacity: 0.5 }); + expect(seriesData.value[1].label).toEqual({ show: true, position: "top" }); + expect(seriesData.value[2].data[0].symbolSize).toBe(10); + }); + + it("should handle nested object data", () => { + const { seriesDataUpdate, seriesData } = usePanelEditor(options); + + const nestedData = [ + { + name: "test", + data: { + values: [1, 2, 3], + metadata: { + source: "api", + timestamp: Date.now(), + }, + }, + }, + ]; + + seriesDataUpdate(nestedData); + + expect(seriesData.value).toEqual(nestedData); + }); + }); + + describe("metaDataValue", () => { + it("should update metaData with provided metadata", () => { + const { metaDataValue, metaData } = usePanelEditor(options); + + const testMetadata = { + total: 100, + took: 50, + scan_records: 1000, + }; + + metaDataValue(testMetadata); + + expect(metaData.value).toEqual(testMetadata); + }); + + it("should handle null metadata", () => { + const { metaDataValue, metaData } = usePanelEditor(options); + + metaDataValue(null); + + expect(metaData.value).toBeNull(); + }); + + it("should replace previous metadata", () => { + const { metaDataValue, metaData } = usePanelEditor(options); + + metaDataValue({ total: 10 }); + metaDataValue({ total: 20 }); + + expect(metaData.value).toEqual({ total: 20 }); + }); + }); + + describe("hoveredSeriesState", () => { + it("should initialize with default values", () => { + const { hoveredSeriesState } = usePanelEditor(options); + + expect(hoveredSeriesState.value.hoveredSeriesName).toBe(""); + expect(hoveredSeriesState.value.panelId).toBe(-1); + expect(hoveredSeriesState.value.dataIndex).toBe(-1); + expect(hoveredSeriesState.value.seriesIndex).toBe(-1); + expect(hoveredSeriesState.value.hoveredTime).toBeNull(); + }); + + it("should update hoveredSeriesName via setHoveredSeriesName", () => { + const { hoveredSeriesState } = usePanelEditor(options); + + hoveredSeriesState.value.setHoveredSeriesName("testSeries"); + + expect(hoveredSeriesState.value.hoveredSeriesName).toBe("testSeries"); + }); + + it("should handle null/undefined in setHoveredSeriesName", () => { + const { hoveredSeriesState } = usePanelEditor(options); + + hoveredSeriesState.value.setHoveredSeriesName(null as any); + + expect(hoveredSeriesState.value.hoveredSeriesName).toBe(""); + }); + + it("should update indices via setIndex", () => { + const { hoveredSeriesState } = usePanelEditor(options); + + hoveredSeriesState.value.setIndex(5, 3, "panel-1", new Date("2024-01-01")); + + expect(hoveredSeriesState.value.dataIndex).toBe(5); + expect(hoveredSeriesState.value.seriesIndex).toBe(3); + expect(hoveredSeriesState.value.panelId).toBe("panel-1"); + expect(hoveredSeriesState.value.hoveredTime).toBeInstanceOf(Date); + }); + + it("should handle null values in setIndex", () => { + const { hoveredSeriesState } = usePanelEditor(options); + + hoveredSeriesState.value.setIndex(null as any, null as any, null as any, null); + + expect(hoveredSeriesState.value.dataIndex).toBe(-1); + expect(hoveredSeriesState.value.seriesIndex).toBe(-1); + expect(hoveredSeriesState.value.panelId).toBe(-1); + expect(hoveredSeriesState.value.hoveredTime).toBeNull(); + }); + }); + + describe("updateVrlFunctionFieldList", () => { + it("should filter out alias fields and update vrlFunctionFieldList", () => { + // Setup with x and y fields that have aliases + dashboardPanelData.data.queries[0].fields.x = [ + { alias: "timestamp", isDerived: false }, + ]; + dashboardPanelData.data.queries[0].fields.y = [ + { alias: "count", isDerived: false }, + ]; + dashboardPanelData.data.queries[0].customQuery = false; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + const fieldList = ["timestamp", "count", "vrlField1", "vrlField2"]; + updateVrlFunctionFieldList(fieldList); + + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "vrlField1", type: "Utf8" }, + { name: "vrlField2", type: "Utf8" }, + ]); + }); + + it("should filter out customQueryFields", () => { + dashboardPanelData.meta.stream.customQueryFields = [{ name: "customField" }]; + dashboardPanelData.data.queries[0].customQuery = true; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + const fieldList = ["customField", "vrlField"]; + updateVrlFunctionFieldList(fieldList); + + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "vrlField", type: "Utf8" }, + ]); + }); + + it("should handle empty field list", () => { + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + updateVrlFunctionFieldList([]); + + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([]); + }); + + it("should be case insensitive when filtering aliases", () => { + dashboardPanelData.data.queries[0].fields.x = [ + { alias: "Timestamp", isDerived: false }, + ]; + dashboardPanelData.data.queries[0].customQuery = false; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + const fieldList = ["timestamp", "TIMESTAMP", "newField"]; + updateVrlFunctionFieldList(fieldList); + + // Both "timestamp" and "TIMESTAMP" should be filtered out + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "newField", type: "Utf8" }, + ]); + }); + + it("should filter out latitude and longitude fields", () => { + dashboardPanelData.data.queries[0].fields.latitude = { + alias: "lat_field", + isDerived: false, + }; + dashboardPanelData.data.queries[0].fields.longitude = { + alias: "lng_field", + isDerived: false, + }; + dashboardPanelData.data.queries[0].customQuery = false; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + const fieldList = ["lat_field", "lng_field", "otherField"]; + updateVrlFunctionFieldList(fieldList); + + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "otherField", type: "Utf8" }, + ]); + }); + + it("should filter out weight, source, target fields", () => { + dashboardPanelData.data.queries[0].fields.weight = { + alias: "weight_val", + isDerived: false, + }; + dashboardPanelData.data.queries[0].fields.source = { + alias: "source_node", + isDerived: false, + }; + dashboardPanelData.data.queries[0].fields.target = { + alias: "target_node", + isDerived: false, + }; + dashboardPanelData.data.queries[0].customQuery = false; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + const fieldList = ["weight_val", "source_node", "target_node", "remainingField"]; + updateVrlFunctionFieldList(fieldList); + + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "remainingField", type: "Utf8" }, + ]); + }); + + it("should filter out value, name, and value_for_maps fields", () => { + dashboardPanelData.data.queries[0].fields.value = { + alias: "val", + isDerived: false, + }; + dashboardPanelData.data.queries[0].fields.name = { + alias: "item_name", + isDerived: false, + }; + dashboardPanelData.data.queries[0].fields.value_for_maps = { + alias: "map_value", + isDerived: false, + }; + dashboardPanelData.data.queries[0].customQuery = false; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + const fieldList = ["val", "item_name", "map_value", "newField"]; + updateVrlFunctionFieldList(fieldList); + + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "newField", type: "Utf8" }, + ]); + }); + + it("should NOT filter out derived fields (isDerived: true)", () => { + dashboardPanelData.data.queries[0].fields.x = [ + { alias: "derivedField", isDerived: true }, + ]; + dashboardPanelData.data.queries[0].fields.y = [ + { alias: "normalField", isDerived: false }, + ]; + dashboardPanelData.data.queries[0].customQuery = false; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + // derivedField should NOT be filtered out because isDerived is true + const fieldList = ["derivedField", "normalField", "vrlField"]; + updateVrlFunctionFieldList(fieldList); + + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "derivedField", type: "Utf8" }, + { name: "vrlField", type: "Utf8" }, + ]); + }); + + it("should filter out breakdown fields", () => { + dashboardPanelData.data.queries[0].fields.breakdown = [ + { alias: "category", isDerived: false }, + { alias: "region", isDerived: false }, + ]; + dashboardPanelData.data.queries[0].customQuery = false; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + const fieldList = ["category", "region", "newField"]; + updateVrlFunctionFieldList(fieldList); + + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "newField", type: "Utf8" }, + ]); + }); + + it("should filter out z-axis fields", () => { + dashboardPanelData.data.queries[0].fields.z = [ + { alias: "z_value", isDerived: false }, + ]; + dashboardPanelData.data.queries[0].customQuery = false; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + const fieldList = ["z_value", "otherField"]; + updateVrlFunctionFieldList(fieldList); + + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "otherField", type: "Utf8" }, + ]); + }); + + it("should handle fields with empty arrays", () => { + // Set fields to empty arrays (valid state) + dashboardPanelData.data.queries[0].fields.x = []; + dashboardPanelData.data.queries[0].fields.y = []; + dashboardPanelData.data.queries[0].fields.z = []; + dashboardPanelData.data.queries[0].fields.breakdown = []; + dashboardPanelData.data.queries[0].customQuery = false; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + // Should not throw and should work with remaining fields + const fieldList = ["field1", "field2"]; + updateVrlFunctionFieldList(fieldList); + + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "field1", type: "Utf8" }, + { name: "field2", type: "Utf8" }, + ]); + }); + + it("should skip filtering when customQuery is true", () => { + // With customQuery=true, x/y/z/breakdown aliases should NOT be filtered + dashboardPanelData.data.queries[0].fields.x = [ + { alias: "timestamp", isDerived: false }, + ]; + dashboardPanelData.data.queries[0].customQuery = true; + dashboardPanelData.meta.stream.customQueryFields = []; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + const fieldList = ["timestamp", "otherField"]; + updateVrlFunctionFieldList(fieldList); + + // Both fields should remain since customQuery is true + expect(dashboardPanelData.meta.stream.vrlFunctionFieldList).toEqual([ + { name: "timestamp", type: "Utf8" }, + { name: "otherField", type: "Utf8" }, + ]); + }); + }); + + describe("isOutDated", () => { + it("should return false for initial dashboard panel data", () => { + // Use default empty panel data + const { isOutDated } = usePanelEditor(options); + + expect(isOutDated.value).toBe(false); + }); + + it("should return false for logs mode with external chart data", () => { + options.pageType = "logs"; + options.externalChartData = ref({ type: "line" }); + + const { isOutDated } = usePanelEditor(options); + + expect(isOutDated.value).toBe(false); + }); + }); + + describe("runQuery", () => { + it("should copy dashboardPanelData.data to chartData", () => { + const { runQuery, chartData } = usePanelEditor(options); + + runQuery(); + + expect(chartData.value).toEqual(dashboardPanelData.data); + }); + + it("should set shouldRefreshWithoutCache when withoutCache is true", () => { + const { runQuery, shouldRefreshWithoutCache } = usePanelEditor(options); + + runQuery(true); + + expect(shouldRefreshWithoutCache.value).toBe(true); + }); + + it("should not set shouldRefreshWithoutCache when withoutCache is false", () => { + const { runQuery, shouldRefreshWithoutCache } = usePanelEditor(options); + + runQuery(false); + + expect(shouldRefreshWithoutCache.value).toBe(false); + }); + + it("should call updateCommittedVariables when available", () => { + const mockUpdateCommittedVariables = vi.fn(); + options.updateCommittedVariables = mockUpdateCommittedVariables; + options.config.showVariablesSelector = true; + + const { runQuery } = usePanelEditor(options); + runQuery(); + + expect(mockUpdateCommittedVariables).toHaveBeenCalled(); + }); + }); + + describe("handleChartApiError", () => { + it("should set errorMessage from string", () => { + const { handleChartApiError, errorMessage, errorData } = usePanelEditor(options); + + handleChartApiError("Test error message"); + + expect(errorMessage.value).toBe("Test error message"); + expect(errorData.errors).toContain("Test error message"); + }); + + it("should set errorMessage from object with message property", () => { + const { handleChartApiError, errorMessage, errorData } = usePanelEditor(options); + + handleChartApiError({ message: "Object error message" }); + + expect(errorMessage.value).toBe("Object error message"); + expect(errorData.errors).toContain("Object error message"); + }); + + it("should clear errorMessage for non-error values", () => { + const { handleChartApiError, errorMessage } = usePanelEditor(options); + + // First set an error + handleChartApiError("Initial error"); + expect(errorMessage.value).toBe("Initial error"); + + // Then clear it + handleChartApiError({}); + + expect(errorMessage.value).toBe(""); + }); + }); + + describe("handleLastTriggeredAtUpdate", () => { + it("should update lastTriggeredAt", () => { + const { handleLastTriggeredAtUpdate, lastTriggeredAt } = usePanelEditor(options); + + const testDate = new Date("2024-01-15T10:30:00Z"); + handleLastTriggeredAtUpdate(testDate); + + expect(lastTriggeredAt.value).toEqual(testDate); + }); + }); + + describe("handleLimitNumberOfSeriesWarningMessage", () => { + it("should update limitNumberOfSeriesWarningMessage", () => { + const { handleLimitNumberOfSeriesWarningMessage, limitNumberOfSeriesWarningMessage } = + usePanelEditor(options); + + handleLimitNumberOfSeriesWarningMessage("Series limit exceeded"); + + expect(limitNumberOfSeriesWarningMessage.value).toBe("Series limit exceeded"); + }); + }); + + describe("handleResultMetadataUpdate", () => { + it("should process metadata and update maxQueryRangeWarning", async () => { + const { handleResultMetadataUpdate, maxQueryRangeWarning } = usePanelEditor(options); + const { processQueryMetadataErrors } = await import("@/utils/zincutils"); + + (processQueryMetadataErrors as any).mockReturnValue("Max query range warning"); + + handleResultMetadataUpdate({ some: "metadata" }); + + expect(processQueryMetadataErrors).toHaveBeenCalled(); + expect(maxQueryRangeWarning.value).toBe("Max query range warning"); + }); + }); + + describe("onDataZoom", () => { + it("should return start and end dates", () => { + const { onDataZoom } = usePanelEditor(options); + + const result = onDataZoom({ + start: new Date("2024-01-01T10:30:45Z").getTime(), + end: new Date("2024-01-01T12:45:30Z").getTime(), + }); + + expect(result.start).toBeInstanceOf(Date); + expect(result.end).toBeInstanceOf(Date); + }); + + it("should truncate seconds from dates", () => { + const { onDataZoom } = usePanelEditor(options); + + const result = onDataZoom({ + start: new Date("2024-01-01T10:30:45Z").getTime(), + end: new Date("2024-01-01T12:45:30Z").getTime(), + }); + + expect(result.start.getSeconds()).toBe(0); + expect(result.start.getMilliseconds()).toBe(0); + expect(result.end.getSeconds()).toBe(0); + expect(result.end.getMilliseconds()).toBe(0); + }); + + it("should add 1 minute to end if start equals end after truncation", () => { + const { onDataZoom } = usePanelEditor(options); + + // Same minute, different seconds - will be equal after truncation + const result = onDataZoom({ + start: new Date("2024-01-01T10:30:15Z").getTime(), + end: new Date("2024-01-01T10:30:45Z").getTime(), + }); + + expect(result.end.getTime() - result.start.getTime()).toBe(60000); // 1 minute + }); + }); + + describe("resetErrors", () => { + it("should clear all error states", () => { + const { + resetErrors, + errorData, + errorMessage, + maxQueryRangeWarning, + limitNumberOfSeriesWarningMessage, + handleChartApiError, + } = usePanelEditor(options); + + // Set some errors first + handleChartApiError("Test error"); + maxQueryRangeWarning.value = "Warning 1"; + limitNumberOfSeriesWarningMessage.value = "Warning 2"; + + expect(errorData.errors.length).toBeGreaterThan(0); + expect(errorMessage.value).toBeTruthy(); + + // Reset errors + resetErrors(); + + expect(errorData.errors).toHaveLength(0); + expect(errorMessage.value).toBe(""); + expect(maxQueryRangeWarning.value).toBe(""); + expect(limitNumberOfSeriesWarningMessage.value).toBe(""); + }); + }); + + describe("updateDateTime", () => { + it("should update dashboardPanelData.meta.dateTime", () => { + const { updateDateTime } = usePanelEditor(options); + + const newDateTime = { + start_time: new Date("2024-02-01T00:00:00Z").getTime(), + end_time: new Date("2024-02-02T00:00:00Z").getTime(), + }; + + updateDateTime(newDateTime); + + expect(dashboardPanelData.meta.dateTime.start_time).toEqual( + new Date("2024-02-01T00:00:00Z") + ); + expect(dashboardPanelData.meta.dateTime.end_time).toEqual( + new Date("2024-02-02T00:00:00Z") + ); + }); + + it("should handle string timestamps", () => { + const { updateDateTime } = usePanelEditor(options); + + const newDateTime = { + start_time: "2024-02-01T00:00:00Z", + end_time: "2024-02-02T00:00:00Z", + }; + + updateDateTime(newDateTime); + + expect(dashboardPanelData.meta.dateTime.start_time).toBeInstanceOf(Date); + expect(dashboardPanelData.meta.dateTime.end_time).toBeInstanceOf(Date); + }); + + it("should not update if dateTime is falsy", () => { + const { updateDateTime } = usePanelEditor(options); + + const originalStartTime = dashboardPanelData.meta.dateTime.start_time; + + updateDateTime(null as any); + + expect(dashboardPanelData.meta.dateTime.start_time).toEqual(originalStartTime); + }); + }); + + describe("isLoading", () => { + it("should return true when searchRequestTraceIds has items", async () => { + const { isLoading, variablesAndPanelsDataLoadingState } = usePanelEditor(options); + + variablesAndPanelsDataLoadingState.searchRequestTraceIds["panel-1"] = ["trace-1"]; + + await nextTick(); + + expect(isLoading.value).toBe(true); + }); + + it("should return true when disable is true", () => { + const { isLoading, disable } = usePanelEditor(options); + + disable.value = true; + + expect(isLoading.value).toBe(true); + }); + + it("should return false when no loading indicators", () => { + const { isLoading } = usePanelEditor(options); + + expect(isLoading.value).toBe(false); + }); + }); + + describe("currentPanelData", () => { + it("should combine renderer data with config", () => { + dashboardPanelData.data.config = { unit: "bytes" }; + + const { currentPanelData, panelSchemaRendererRef } = usePanelEditor(options); + + // Mock panelSchemaRendererRef + panelSchemaRendererRef.value = { + panelData: { series: [{ data: [1, 2, 3] }] }, + }; + + expect(currentPanelData.value.series).toEqual([{ data: [1, 2, 3] }]); + expect(currentPanelData.value.config).toEqual({ unit: "bytes" }); + }); + + it("should handle missing panelSchemaRendererRef", () => { + dashboardPanelData.data.config = { unit: "bytes" }; + + const { currentPanelData } = usePanelEditor(options); + + expect(currentPanelData.value.config).toEqual({ unit: "bytes" }); + }); + }); + + describe("Multiple Method Calls Together", () => { + it("should handle multiple method calls in sequence", async () => { + const { + seriesDataUpdate, + metaDataValue, + handleChartApiError, + collapseFieldList, + seriesData, + metaData, + errorMessage, + } = usePanelEditor(options); + + // Call multiple methods in sequence + seriesDataUpdate([{ name: "test", data: [1, 2, 3] }]); + metaDataValue({ total: 100, took: 50 }); + handleChartApiError("Test error"); + collapseFieldList(); + + // Verify all state updates + expect(seriesData.value).toEqual([{ name: "test", data: [1, 2, 3] }]); + expect(metaData.value).toEqual({ total: 100, took: 50 }); + expect(errorMessage.value).toBe("Test error"); + expect(dashboardPanelData.layout.showFieldList).toBe(false); + }); + + it("should handle runQuery followed by error handling", () => { + const { runQuery, handleChartApiError, chartData, errorMessage } = + usePanelEditor(options); + + // Run query + runQuery(); + expect(chartData.value).toBeDefined(); + + // Then handle error + handleChartApiError("Query failed"); + expect(errorMessage.value).toBe("Query failed"); + }); + + it("should handle resetErrors after multiple errors", () => { + const { + handleChartApiError, + handleLimitNumberOfSeriesWarningMessage, + resetErrors, + errorMessage, + limitNumberOfSeriesWarningMessage, + maxQueryRangeWarning, + } = usePanelEditor(options); + + // Set multiple errors + handleChartApiError("Error 1"); + handleLimitNumberOfSeriesWarningMessage("Series limit warning"); + maxQueryRangeWarning.value = "Query range warning"; + + // Verify errors are set + expect(errorMessage.value).toBe("Error 1"); + expect(limitNumberOfSeriesWarningMessage.value).toBe("Series limit warning"); + expect(maxQueryRangeWarning.value).toBe("Query range warning"); + + // Reset all + resetErrors(); + + // Verify all cleared + expect(errorMessage.value).toBe(""); + expect(limitNumberOfSeriesWarningMessage.value).toBe(""); + expect(maxQueryRangeWarning.value).toBe(""); + }); + }); + + describe("Edge Cases", () => { + it("should handle single query with minimal fields", () => { + // Test with minimal but valid query structure + dashboardPanelData.data.queries = [ + { + query: "", + customQuery: false, + fields: { + x: [], + y: [], + z: [], + breakdown: [], + filter: { conditions: [] }, + }, + }, + ]; + + // Should not throw when accessing queries + expect(() => usePanelEditor(options)).not.toThrow(); + }); + + it("should handle partial field definitions", () => { + dashboardPanelData.data.queries[0].fields = { + x: [{ alias: "timestamp" }], // Missing isDerived + y: [], // Empty array + // Missing z, breakdown, filter + }; + dashboardPanelData.data.queries[0].customQuery = false; + + const { updateVrlFunctionFieldList } = usePanelEditor(options); + + // Should not throw + expect(() => { + updateVrlFunctionFieldList(["timestamp", "newField"]); + }).not.toThrow(); + }); + + it("should handle undefined dashboardPanelData properties", () => { + // The composable should handle missing nested properties + expect(() => usePanelEditor(options)).not.toThrow(); + }); + + it("should handle splitter value updates correctly", () => { + const { collapseFieldList } = usePanelEditor(options); + + // Set initial values + dashboardPanelData.layout.splitter = 50; + dashboardPanelData.layout.showFieldList = true; + + // Collapse + collapseFieldList(); + expect(dashboardPanelData.layout.splitter).toBe(0); + expect(dashboardPanelData.layout.showFieldList).toBe(false); + + // Expand + collapseFieldList(); + expect(dashboardPanelData.layout.splitter).toBe(20); + expect(dashboardPanelData.layout.showFieldList).toBe(true); + }); + + it("should handle querySplitterUpdated with different heights", () => { + const dispatchEventSpy = vi.spyOn(window, "dispatchEvent"); + dashboardPanelData.layout.showQueryBar = true; + + const { querySplitterUpdated, expandedSplitterHeight } = usePanelEditor(options); + + // Test with various heights + querySplitterUpdated(25); + expect(expandedSplitterHeight.value).toBe(25); + + querySplitterUpdated(75); + expect(expandedSplitterHeight.value).toBe(75); + + querySplitterUpdated(100); + expect(expandedSplitterHeight.value).toBe(100); + + // Verify resize dispatched for each + expect(dispatchEventSpy).toHaveBeenCalledTimes(3); + }); + + it("should handle dateTimePickerRef when available", () => { + const mockRefresh = vi.fn(); + options.dateTimePickerRef = ref({ + refresh: mockRefresh, + }); + + const { runQuery } = usePanelEditor(options); + runQuery(); + + expect(mockRefresh).toHaveBeenCalled(); + }); + + it("should handle selectedDate when available", () => { + options.selectedDate = ref({ + start_time: new Date("2024-03-01").getTime(), + end_time: new Date("2024-03-02").getTime(), + }); + + const { runQuery } = usePanelEditor(options); + runQuery(); + + expect(dashboardPanelData.meta.dateTime.start_time).toEqual( + new Date("2024-03-01") + ); + }); + }); + + describe("Watchers", () => { + it("should update chartData when chart type changes", async () => { + const { chartData, runQuery } = usePanelEditor(options); + + // Initially undefined + expect(chartData.value).toBeUndefined(); + + // First run query to set initial chartData + runQuery(); + expect(chartData.value?.type).toBe("line"); + + // Change chart type + dashboardPanelData.data.type = "bar"; + + await nextTick(); + await nextTick(); // May need extra tick for watcher + + // chartData should be updated via watcher + expect(chartData.value).toBeDefined(); + expect(chartData.value?.type).toBe("bar"); + }); + + it("should dispatch resize when isConfigPanelOpen changes", async () => { + const dispatchEventSpy = vi.spyOn(window, "dispatchEvent"); + + usePanelEditor(options); + + // Change config panel state + dashboardPanelData.layout.isConfigPanelOpen = true; + + await nextTick(); + + expect(dispatchEventSpy).toHaveBeenCalled(); + }); + + it("should sync showFieldList when splitter changes", async () => { + usePanelEditor(options); + + // Set splitter to 0, showFieldList should become false + dashboardPanelData.layout.splitter = 0; + + await nextTick(); + + expect(dashboardPanelData.layout.showFieldList).toBe(false); + + // Set splitter > 0, showFieldList should become true + dashboardPanelData.layout.splitter = 15; + + await nextTick(); + + expect(dashboardPanelData.layout.showFieldList).toBe(true); + }); + }); +}); diff --git a/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.ts b/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.ts new file mode 100644 index 0000000000..5bd55fa2f2 --- /dev/null +++ b/web/src/components/dashboards/PanelEditor/composables/usePanelEditor.ts @@ -0,0 +1,795 @@ +// Copyright 2023 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { ref, reactive, computed, watch, provide, inject, nextTick } from "vue"; +import type { Ref, ComputedRef } from "vue"; +import { useStore } from "vuex"; +import { isEqual } from "lodash-es"; + +import type { + PanelEditorPageType, + PanelEditorConfig, + PanelEditorChartData, + PanelEditorErrorData, + PanelEditorDateTime, + PanelEditorVariablesData, +} from "../types/panelEditor"; + +import { checkIfConfigChangeRequiredApiCallOrNot } from "@/utils/dashboard/checkConfigChangeApiCall"; +import { processQueryMetadataErrors } from "@/utils/zincutils"; +import useCancelQuery from "@/composables/dashboard/useCancelQuery"; +import useNotifications from "@/composables/useNotifications"; + +/** + * Options for usePanelEditor composable + */ +export interface UsePanelEditorOptions { + /** The page type - determines default behavior */ + pageType: PanelEditorPageType; + /** Resolved configuration (after merging props with presets) */ + config: PanelEditorConfig; + /** Dashboard panel data from useDashboardPanelData composable */ + dashboardPanelData: any; + /** Whether in edit mode (editing existing panel vs creating new) */ + editMode: Ref; + /** External chart data (for logs mode) */ + externalChartData?: Ref; + /** Variables data (for dashboard mode) */ + variablesData?: PanelEditorVariablesData; + /** Committed variables data (for dashboard mode - what the chart is currently using) */ + updatedVariablesData?: PanelEditorVariablesData; + /** Function to update committed variables (dashboard mode) */ + updateCommittedVariables?: () => void; + /** DateTime picker ref (for dashboard mode) */ + dateTimePickerRef?: Ref; + /** Selected date value */ + selectedDate?: Ref; + /** Validate panel fields (from useDashboardPanelData) */ + validatePanel?: (errors: string[], isFieldsValidationRequired?: boolean) => void; +} + +/** + * Combined composable for PanelEditor component. + * Handles all shared state and actions across dashboard, metrics, and logs pages. + */ +export function usePanelEditor(options: UsePanelEditorOptions) { + const { + pageType, + config, + dashboardPanelData, + editMode, + externalChartData, + variablesData, + updatedVariablesData, + updateCommittedVariables, + dateTimePickerRef, + selectedDate, + validatePanel, + } = options; + + const store = useStore(); + const { showErrorNotification } = useNotifications(); + + // ============================================================================ + // State Refs + // ============================================================================ + + /** Current chart data - the data currently being rendered */ + const chartData: Ref = ref(undefined); + + /** Error data from chart queries */ + const errorData: PanelEditorErrorData = reactive({ + errors: [], + }); + + /** Metadata from query execution (for QueryInspector) */ + const metaData: Ref = ref(null); + + /** Series data from chart rendering */ + const seriesData: Ref = ref([]); + + /** Timestamp of last query execution */ + const lastTriggeredAt: Ref = ref(null); + + /** Whether to show legends dialog */ + const showLegendsDialog: Ref = ref(false); + + /** Whether to show add to dashboard dialog */ + const showAddToDashboardDialog: Ref = ref(false); + + /** Whether to refresh without using cache */ + const shouldRefreshWithoutCache: Ref = ref(false); + + // ---- Warning Messages ---- + + /** Max query range warning message */ + const maxQueryRangeWarning: Ref = ref(""); + + /** Series limit warning message */ + const limitNumberOfSeriesWarningMessage: Ref = ref(""); + + /** General error message */ + const errorMessage: Ref = ref(""); + + // ---- Panel State Flags ---- + + /** Whether the data shown is incomplete (loading was interrupted) */ + const isPartialData: Ref = ref(false); + + /** Whether the panel is currently loading */ + const isPanelLoading: Ref = ref(false); + + /** Whether cached data differs from current time range */ + const isCachedDataDifferWithCurrentTimeRange: Ref = ref(false); + + // ---- Loading State ---- + + /** Whether queries are disabled (loading) */ + const disable: Ref = ref(false); + + /** + * Reactive object for loading state of variables and panels. + * First try to inject from parent (e.g., Index.vue for logs page). + * If no parent provides it, create a new one (e.g., for dashboard AddPanel). + * This ensures PanelSchemaRenderer updates the correct state that SearchBar reads from. + */ + const injectedLoadingState = inject("variablesAndPanelsDataLoadingState", null); + + const variablesAndPanelsDataLoadingState = injectedLoadingState || reactive({ + variablesData: {} as Record, + panels: {} as Record, + searchRequestTraceIds: {} as Record, + }); + + // Provide loading state for child components (either injected or newly created) + // This ensures PanelSchemaRenderer can inject it + provide( + "variablesAndPanelsDataLoadingState", + variablesAndPanelsDataLoadingState, + ); + + /** Computed array of search request trace IDs (for cancel functionality) */ + const searchRequestTraceIds: ComputedRef = computed(() => { + const searchIds = Object.values( + variablesAndPanelsDataLoadingState.searchRequestTraceIds, + ).filter((item: any) => item.length > 0); + return searchIds.flat() as string[]; + }); + + // ---- Cancel Query Support ---- + const { traceIdRef, cancelQuery } = useCancelQuery(); + + // ---- Hovered Series State (for chart interactions) ---- + const hoveredSeriesState = ref({ + hoveredSeriesName: "", + panelId: -1, + dataIndex: -1, + seriesIndex: -1, + hoveredTime: null as any, + setHoveredSeriesName: function (name: string) { + hoveredSeriesState.value.hoveredSeriesName = name ?? ""; + }, + setIndex: function ( + dataIndex: number, + seriesIndex: number, + panelId: any, + hoveredTime?: any, + ) { + hoveredSeriesState.value.dataIndex = dataIndex ?? -1; + hoveredSeriesState.value.seriesIndex = seriesIndex ?? -1; + hoveredSeriesState.value.panelId = panelId ?? -1; + hoveredSeriesState.value.hoveredTime = hoveredTime ?? null; + }, + }); + + // Provide hovered series state for child components + provide("hoveredSeriesState", hoveredSeriesState); + + // ---- Panel Schema Renderer Ref ---- + const panelSchemaRendererRef: Ref = ref(null); + + // ---- Splitter State ---- + const splitterModel: Ref = ref(50); + const expandedSplitterHeight: Ref = ref(null); + + // ============================================================================ + // Computed Properties + // ============================================================================ + + /** + * Check if this is initial dashboard panel data (no fields configured) + */ + const isInitialDashboardPanelData = (): boolean => { + return ( + dashboardPanelData.data.description === "" && + !dashboardPanelData.data.config.unit && + !dashboardPanelData.data.config.unit_custom && + dashboardPanelData.data.queries[0].fields.x.length === 0 && + dashboardPanelData.data.queries[0].fields?.breakdown?.length === 0 && + dashboardPanelData.data.queries[0].fields.y.length === 0 && + dashboardPanelData.data.queries[0].fields.z.length === 0 && + dashboardPanelData.data.queries[0].fields.filter.conditions.length === + 0 && + dashboardPanelData.data.queries.length === 1 + ); + }; + + /** + * Whether the chart is out of date (panel data differs from chart data) + */ + const isOutDated: ComputedRef = computed(() => { + // Check if this is initial add panel call + if (isInitialDashboardPanelData() && !editMode.value) return false; + + // For logs mode, we don't show outdated warning based on panel data comparison + // since data comes from external props + if (pageType === "logs" && externalChartData?.value) { + return false; + } + + // Helper function to normalize variables for comparison + const normalizeVariables = (obj: any) => { + const normalized = JSON.parse(JSON.stringify(obj)); + // Sort arrays to ensure consistent ordering + if (normalized.values && Array.isArray(normalized.values)) { + normalized.values = normalized.values + .map((variable: any) => { + if (Array.isArray(variable.value)) { + variable.value.sort((a: any, b: any) => + JSON.stringify(a).localeCompare(JSON.stringify(b)), + ); + } + return variable; + }) + .sort((a: any, b: any) => a.name.localeCompare(b.name)); + } + return normalized; + }; + + // For dashboard mode, also compare variables + let variablesChanged = false; + if (config.showVariablesSelector && variablesData && updatedVariablesData) { + const normalizedCurrent = normalizeVariables(variablesData); + const normalizedRefreshed = normalizeVariables(updatedVariablesData); + variablesChanged = !isEqual(normalizedCurrent, normalizedRefreshed); + } + + // Compare chart data with panel data + const configChanged = !isEqual( + JSON.parse(JSON.stringify(chartData.value ?? {})), + JSON.parse(JSON.stringify(dashboardPanelData.data ?? {})), + ); + + let configNeedsApiCall = false; + if (configChanged) { + configNeedsApiCall = checkIfConfigChangeRequiredApiCallOrNot( + chartData.value, + dashboardPanelData.data, + ); + } + + return configNeedsApiCall || variablesChanged; + }); + + /** + * Current panel data (combines renderer data with config) + * Used by ShowLegendsPopup + */ + const currentPanelData = computed(() => { + const rendererData = panelSchemaRendererRef.value?.panelData || {}; + return { + ...rendererData, + config: dashboardPanelData.data.config || {}, + }; + }); + + /** + * Whether the component is in loading state + */ + const isLoading: ComputedRef = computed(() => { + return searchRequestTraceIds.value.length > 0 || disable.value; + }); + + // ============================================================================ + // Action Functions + // ============================================================================ + + /** + * Run the chart query + * @param withoutCache - If true, bypass cache (enterprise feature) + */ + const runQuery = (withoutCache = false): void => { + try { + // Validate panel fields before running query (matches main branch AddPanel.vue behavior) + // Uses validatePanel from useDashboardPanelData which checks all field requirements + if (validatePanel) { + const errors = errorData.errors; + errors.splice(0); + validatePanel(errors, true); + + if (errors.length) { + showErrorNotification( + "There are some errors, please fix them and try again", + ); + // Do not return early — query still fires to allow partial results + } + } + + // Set cache flag + shouldRefreshWithoutCache.value = withoutCache; + + // For dashboard mode, commit the current variable values + if (config.showVariablesSelector && updateCommittedVariables) { + updateCommittedVariables(); + } + + // Copy the data object excluding the reactivity + chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data)); + + // Refresh the date time picker if available + if (dateTimePickerRef?.value) { + dateTimePickerRef.value.refresh(); + } + + // Update datetime + if (selectedDate?.value) { + updateDateTime(selectedDate.value); + } + } catch (err) { + // Error during query execution - silently handle + console.error("Error during runQuery:", err); + } + }; + + /** + * Handle chart API errors + * @param errorMsg - Error message from chart API + */ + const handleChartApiError = (errorMsg: any): void => { + if (typeof errorMsg === "string") { + errorMessage.value = errorMsg; + const errorList = errorData.errors ?? []; + errorList.splice(0); + errorList.push(errorMsg); + } else if (errorMsg?.message) { + errorMessage.value = errorMsg.message ?? ""; + const errorList = errorData.errors ?? []; + errorList.splice(0); + errorList.push(errorMsg.message); + } else { + errorMessage.value = ""; + } + }; + + /** + * Handle last triggered at timestamp update + * @param data - Timestamp data + */ + const handleLastTriggeredAtUpdate = (data: any): void => { + lastTriggeredAt.value = data; + }; + + /** + * Handle series limit warning message + * @param message - Warning message + */ + const handleLimitNumberOfSeriesWarningMessage = (message: string): void => { + limitNumberOfSeriesWarningMessage.value = message; + }; + + /** + * Handle partial data update from PanelSchemaRenderer + * @param value - Whether data is partial (loading was interrupted) + */ + const handleIsPartialDataUpdate = (value: boolean): void => { + isPartialData.value = value; + }; + + /** + * Handle loading state change from PanelSchemaRenderer + * @param value - Whether panel is loading + */ + const handleLoadingStateChange = (value: boolean): void => { + isPanelLoading.value = value; + }; + + /** + * Handle cached data differs from current time range update + * @param value - Whether cached data differs from current time range + */ + const handleIsCachedDataDifferWithCurrentTimeRangeUpdate = (value: boolean): void => { + isCachedDataDifferWithCurrentTimeRange.value = value; + }; + + /** + * Handle result metadata update (for QueryInspector) + * @param metadata - Query metadata + */ + const handleResultMetadataUpdate = (metadata: any): void => { + maxQueryRangeWarning.value = processQueryMetadataErrors( + metadata, + store.state.timezone, + ); + }; + + /** + * Update metadata value + * @param metadata - Metadata from query + */ + const metaDataValue = (metadata: any): void => { + metaData.value = metadata; + }; + + /** + * Update series data + * @param data - Series data from chart + */ + const seriesDataUpdate = (data: any): void => { + seriesData.value = data; + }; + + /** + * Collapse or expand the field list sidebar + */ + const collapseFieldList = (): void => { + if (dashboardPanelData.layout.showFieldList) { + dashboardPanelData.layout.splitter = 0; + dashboardPanelData.layout.showFieldList = false; + } else { + dashboardPanelData.layout.splitter = 20; + dashboardPanelData.layout.showFieldList = true; + } + }; + + /** + * Handle layout splitter update (triggers resize) + */ + const layoutSplitterUpdated = (): void => { + window.dispatchEvent(new Event("resize")); + }; + + /** + * Handle query splitter update + * @param newHeight - New height value + */ + const querySplitterUpdated = (newHeight: any): void => { + window.dispatchEvent(new Event("resize")); + if (dashboardPanelData.layout.showQueryBar) { + expandedSplitterHeight.value = newHeight; + } + }; + + /** + * Update VRL function field list + * @param fieldList - List of fields + */ + const updateVrlFunctionFieldList = (fieldList: any): void => { + // Extract all panelSchema alias + const aliasList: any[] = []; + + // If auto sql + if ( + dashboardPanelData.data.queries[ + dashboardPanelData.layout.currentQueryIndex + ].customQuery === false + ) { + // Add x axis alias + dashboardPanelData?.data?.queries[ + dashboardPanelData.layout.currentQueryIndex + ]?.fields?.x?.forEach((it: any) => { + if (!it.isDerived) { + aliasList.push(it.alias); + } + }); + + // Add breakdown alias + dashboardPanelData?.data?.queries[ + dashboardPanelData.layout.currentQueryIndex + ]?.fields?.breakdown?.forEach((it: any) => { + if (!it.isDerived) { + aliasList.push(it.alias); + } + }); + + // Add y axis alias + dashboardPanelData?.data?.queries[ + dashboardPanelData.layout.currentQueryIndex + ]?.fields?.y?.forEach((it: any) => { + if (!it.isDerived) { + aliasList.push(it.alias); + } + }); + + // Add z axis alias + dashboardPanelData?.data?.queries[ + dashboardPanelData.layout.currentQueryIndex + ]?.fields?.z?.forEach((it: any) => { + if (!it.isDerived) { + aliasList.push(it.alias); + } + }); + + // Add special field aliases (latitude, longitude, weight, source, target, value, name, value_for_maps) + const specialFields = [ + "latitude", + "longitude", + "weight", + "source", + "target", + "value", + "name", + "value_for_maps", + ]; + specialFields.forEach((fieldName) => { + const field = + dashboardPanelData?.data?.queries[ + dashboardPanelData.layout.currentQueryIndex + ]?.fields?.[fieldName]; + if (field?.alias && !field?.isDerived) { + aliasList.push(field.alias); + } + }); + } + + // Remove custom query fields from field list + dashboardPanelData.meta.stream.customQueryFields.forEach((it: any) => { + aliasList.push(it.name); + }); + + // Rest will be vrl function fields + const filteredFieldList = fieldList + .filter( + (field: any) => + !aliasList.some( + (alias: string) => alias.toLowerCase() === field.toLowerCase(), + ), + ) + .map((field: any) => ({ name: field, type: "Utf8" })); + + dashboardPanelData.meta.stream.vrlFunctionFieldList = filteredFieldList; + }; + + /** + * Handle data zoom event (chart zoom) + * @param event - Zoom event with start and end + * @returns Object with start and end dates for parent to handle + */ + const onDataZoom = (event: any): { start: Date; end: Date } => { + const selectedDateObj = { + start: new Date(event.start), + end: new Date(event.end), + }; + + // Truncate seconds and milliseconds from the dates + selectedDateObj.start.setSeconds(0, 0); + selectedDateObj.end.setSeconds(0, 0); + + // Compare the truncated dates + if (selectedDateObj.start.getTime() === selectedDateObj.end.getTime()) { + // Increment the end date by 1 minute + selectedDateObj.end.setMinutes(selectedDateObj.end.getMinutes() + 1); + } + + // For dashboard mode, update datetime picker if available + if (dateTimePickerRef?.value) { + dateTimePickerRef.value.setCustomDate("absolute", selectedDateObj); + } + + return selectedDateObj; + }; + + /** + * Cancel running queries + */ + const cancelRunningQuery = (): void => { + traceIdRef.value = searchRequestTraceIds.value; + cancelQuery(); + }; + + /** + * Reset error state + */ + const resetErrors = (): void => { + errorData.errors.splice(0); + errorMessage.value = ""; + maxQueryRangeWarning.value = ""; + limitNumberOfSeriesWarningMessage.value = ""; + }; + + /** + * Update the datetime for queries + * @param dateTime - The new datetime object + */ + const updateDateTime = (dateTime: PanelEditorDateTime): void => { + if (dateTime) { + dashboardPanelData.meta.dateTime = { + start_time: new Date(dateTime.start_time), + end_time: new Date(dateTime.end_time), + }; + } + }; + + // ============================================================================ + // Watchers + // ============================================================================ + + // Watch for chart type changes - update chartData + watch( + () => dashboardPanelData.data.type, + async () => { + await nextTick(); + chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data)); + }, + ); + + // Watch for config changes - auto-apply if no API call needed + watch( + () => dashboardPanelData.data.config, + () => { + // Check if the config change requires an API call + const needsApiCall = checkIfConfigChangeRequiredApiCallOrNot( + chartData.value, + dashboardPanelData.data, + ); + + // If no API call needed, auto-apply the config change + if (!needsApiCall) { + chartData.value = JSON.parse(JSON.stringify(dashboardPanelData.data)); + } + }, + { deep: true }, + ); + + // Watch for config panel open/close - trigger resize + watch( + () => dashboardPanelData.layout.isConfigPanelOpen, + () => { + window.dispatchEvent(new Event("resize")); + }, + ); + + // Watch isOutDated - trigger resize + watch(isOutDated, () => { + window.dispatchEvent(new Event("resize")); + }); + + // Watch splitter changes - sync showFieldList + watch( + () => dashboardPanelData.layout.splitter, + (newVal) => { + if (newVal > 0 && !dashboardPanelData.layout.showFieldList) { + dashboardPanelData.layout.showFieldList = true; + } else if (newVal === 0 && dashboardPanelData.layout.showFieldList) { + dashboardPanelData.layout.showFieldList = false; + } + }, + ); + + // Watch query bar visibility - update splitter + watch( + () => dashboardPanelData.layout.showQueryBar, + (newValue) => { + if (!newValue) { + dashboardPanelData.layout.querySplitter = 41; + } else { + if (expandedSplitterHeight.value !== null) { + dashboardPanelData.layout.querySplitter = + expandedSplitterHeight.value; + } + } + }, + ); + + // Watch loading state - update disable + watch(variablesAndPanelsDataLoadingState, () => { + const panelsValues = Object.values( + variablesAndPanelsDataLoadingState.panels, + ); + disable.value = panelsValues.some((item: any) => item === true); + }); + + // Check if externalChartData has actual VALUE (not just if the ref exists) + // A ref is always truthy even if its value is undefined, so we must check .value + const hasExternalChartData = externalChartData && externalChartData.value !== undefined; + + // Watch external chart data (for logs mode) - sync to internal state + // Only set up this watcher if externalChartData actually has data + if (hasExternalChartData) { + watch( + externalChartData, + (newData) => { + if (newData) { + chartData.value = JSON.parse(JSON.stringify(newData)); + } + }, + { immediate: true }, + ); + } + + // ---- Chart Data Initialization ---- + + /** + * Initialize chartData from dashboardPanelData. + * Called by parent component (e.g., AddPanel) after loading panel data in onMounted. + * This replaces the watcher approach and follows main branch pattern. + * + * @param data - Optional data to initialize with. If not provided, uses dashboardPanelData.data + */ + const initChartData = (data?: any) => { + const sourceData = data ?? dashboardPanelData.data; + if (sourceData) { + chartData.value = JSON.parse(JSON.stringify(sourceData)); + } else { + chartData.value = {}; + } + + }; + + // ============================================================================ + // Return + // ============================================================================ + + return { + // State refs + chartData, + errorData, + metaData, + seriesData, + lastTriggeredAt, + showLegendsDialog, + showAddToDashboardDialog, + shouldRefreshWithoutCache, + maxQueryRangeWarning, + limitNumberOfSeriesWarningMessage, + errorMessage, + isPartialData, + isPanelLoading, + isCachedDataDifferWithCurrentTimeRange, + disable, + searchRequestTraceIds, + hoveredSeriesState, + panelSchemaRendererRef, + splitterModel, + expandedSplitterHeight, + variablesAndPanelsDataLoadingState, + + // Computed + isOutDated, + isLoading, + currentPanelData, + isInitialDashboardPanelData, + + // Actions + initChartData, + runQuery, + handleChartApiError, + handleLastTriggeredAtUpdate, + handleLimitNumberOfSeriesWarningMessage, + handleIsPartialDataUpdate, + handleLoadingStateChange, + handleIsCachedDataDifferWithCurrentTimeRangeUpdate, + handleResultMetadataUpdate, + metaDataValue, + seriesDataUpdate, + collapseFieldList, + layoutSplitterUpdated, + querySplitterUpdated, + updateVrlFunctionFieldList, + onDataZoom, + cancelRunningQuery, + resetErrors, + updateDateTime, + }; +} diff --git a/web/src/components/dashboards/PanelEditor/index.ts b/web/src/components/dashboards/PanelEditor/index.ts new file mode 100644 index 0000000000..9867a29079 --- /dev/null +++ b/web/src/components/dashboards/PanelEditor/index.ts @@ -0,0 +1,78 @@ +// Copyright 2023 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// ============================================================================ +// Component Export +// ============================================================================ + +export { default as PanelEditor } from "./PanelEditor.vue"; + +// ============================================================================ +// Type Exports +// ============================================================================ + +export type { + // Page type + PanelEditorPageType, + + // Props and configuration + PanelEditorProps, + PanelEditorConfig, + + // Events + PanelEditorEmits, + + // Exposed interface + PanelEditorExpose, + + // Data types + PanelEditorDateTime, + PanelEditorVariablesData, + PanelEditorErrorData, + PanelEditorSearchResponse, + PanelEditorChartData, + PanelEditorDashboardData, + + // Chart types + ChartType, +} from "./types/panelEditor"; + +// ============================================================================ +// Constant Exports +// ============================================================================ + +export { + // Preset configurations + DASHBOARD_PRESET, + METRICS_PRESET, + LOGS_PRESET, + BUILD_PRESET, + + // Functions + getPresetByPageType, + resolveConfig, + + // Chart type constants + ALL_CHART_TYPES, + SELECT_STAR_ALLOWED_CHART_TYPES, + AGGREGATION_REQUIRED_CHART_TYPES, +} from "./types/panelEditor"; + +// ============================================================================ +// Composable Export +// ============================================================================ + +export { usePanelEditor } from "./composables/usePanelEditor"; +export type { UsePanelEditorOptions } from "./composables/usePanelEditor"; diff --git a/web/src/components/dashboards/PanelEditor/types/panelEditor.spec.ts b/web/src/components/dashboards/PanelEditor/types/panelEditor.spec.ts new file mode 100644 index 0000000000..b4ebb5a0ec --- /dev/null +++ b/web/src/components/dashboards/PanelEditor/types/panelEditor.spec.ts @@ -0,0 +1,441 @@ +import { describe, it, expect } from "vitest"; +import { + getPresetByPageType, + resolveConfig, + DASHBOARD_PRESET, + METRICS_PRESET, + LOGS_PRESET, + BUILD_PRESET, + ALL_CHART_TYPES, + SELECT_STAR_ALLOWED_CHART_TYPES, + AGGREGATION_REQUIRED_CHART_TYPES, + type PanelEditorPageType, + type PanelEditorConfig, + type PanelEditorProps, +} from "./panelEditor"; + +describe("panelEditor types and utilities", () => { + describe("Preset Configurations", () => { + describe("DASHBOARD_PRESET", () => { + it("should have correct default configuration", () => { + expect(DASHBOARD_PRESET).toEqual({ + showQueryEditor: true, + showQueryBuilder: true, + showVariablesSelector: true, + showLastRefreshedTime: true, + showOutdatedWarning: true, + showAddToDashboardButton: false, + showQueryTypeSelector: false, + showGeneratedQueryDisplay: false, + hideChartPreview: false, + }); + }); + + it("should not show Add to Dashboard button (has Save button instead)", () => { + expect(DASHBOARD_PRESET.showAddToDashboardButton).toBe(false); + }); + + it("should show variables selector", () => { + expect(DASHBOARD_PRESET.showVariablesSelector).toBe(true); + }); + }); + + describe("METRICS_PRESET", () => { + it("should have correct default configuration", () => { + expect(METRICS_PRESET).toEqual({ + showQueryEditor: true, + showQueryBuilder: true, + showVariablesSelector: false, + showLastRefreshedTime: true, + showOutdatedWarning: true, + showAddToDashboardButton: true, + showQueryTypeSelector: false, + showGeneratedQueryDisplay: false, + hideChartPreview: false, + }); + }); + + it("should show Add to Dashboard button", () => { + expect(METRICS_PRESET.showAddToDashboardButton).toBe(true); + }); + + it("should not show variables selector", () => { + expect(METRICS_PRESET.showVariablesSelector).toBe(false); + }); + }); + + describe("LOGS_PRESET", () => { + it("should have correct default configuration", () => { + expect(LOGS_PRESET).toEqual({ + showQueryEditor: false, + showQueryBuilder: false, + showVariablesSelector: false, + showLastRefreshedTime: false, + showOutdatedWarning: true, + showAddToDashboardButton: true, + showQueryTypeSelector: false, + showGeneratedQueryDisplay: false, + hideChartPreview: false, + }); + }); + + it("should not show query editor (logs visualization receives external data)", () => { + expect(LOGS_PRESET.showQueryEditor).toBe(false); + }); + + it("should not show query builder (logs visualization receives external data)", () => { + expect(LOGS_PRESET.showQueryBuilder).toBe(false); + }); + + it("should show Add to Dashboard button", () => { + expect(LOGS_PRESET.showAddToDashboardButton).toBe(true); + }); + }); + + describe("BUILD_PRESET", () => { + it("should have correct default configuration", () => { + expect(BUILD_PRESET).toEqual({ + showQueryEditor: false, + showQueryBuilder: true, + showVariablesSelector: false, + showLastRefreshedTime: false, + showOutdatedWarning: false, + showAddToDashboardButton: true, + showQueryTypeSelector: true, + showGeneratedQueryDisplay: true, + hideChartPreview: false, + }); + }); + + it("should show query type selector (SQL/PromQL toggle)", () => { + expect(BUILD_PRESET.showQueryTypeSelector).toBe(true); + }); + + it("should show generated query display", () => { + expect(BUILD_PRESET.showGeneratedQueryDisplay).toBe(true); + }); + + it("should show query builder for X/Y/Breakdown fields", () => { + expect(BUILD_PRESET.showQueryBuilder).toBe(true); + }); + + it("should not show outdated warning", () => { + expect(BUILD_PRESET.showOutdatedWarning).toBe(false); + }); + }); + }); + + describe("getPresetByPageType", () => { + it("should return DASHBOARD_PRESET for dashboard page type", () => { + expect(getPresetByPageType("dashboard")).toBe(DASHBOARD_PRESET); + }); + + it("should return METRICS_PRESET for metrics page type", () => { + expect(getPresetByPageType("metrics")).toBe(METRICS_PRESET); + }); + + it("should return LOGS_PRESET for logs page type", () => { + expect(getPresetByPageType("logs")).toBe(LOGS_PRESET); + }); + + it("should return BUILD_PRESET for build page type", () => { + expect(getPresetByPageType("build")).toBe(BUILD_PRESET); + }); + + it("should return DASHBOARD_PRESET as default for unknown page type", () => { + expect(getPresetByPageType("unknown" as PanelEditorPageType)).toBe( + DASHBOARD_PRESET, + ); + }); + }); + + describe("resolveConfig", () => { + it("should return preset defaults when no overrides provided", () => { + const props: PanelEditorProps = { + pageType: "dashboard", + }; + + const config = resolveConfig(props); + + expect(config).toEqual(DASHBOARD_PRESET); + }); + + it("should override showQueryEditor when provided in props", () => { + const props: PanelEditorProps = { + pageType: "dashboard", + showQueryEditor: false, + }; + + const config = resolveConfig(props); + + expect(config.showQueryEditor).toBe(false); + // Other values should remain from preset + expect(config.showVariablesSelector).toBe(true); + }); + + it("should override showQueryBuilder when provided in props", () => { + const props: PanelEditorProps = { + pageType: "logs", + showQueryBuilder: true, + }; + + const config = resolveConfig(props); + + expect(config.showQueryBuilder).toBe(true); + // Verify it was originally false in LOGS_PRESET + expect(LOGS_PRESET.showQueryBuilder).toBe(false); + }); + + it("should override showVariablesSelector when provided in props", () => { + const props: PanelEditorProps = { + pageType: "metrics", + showVariablesSelector: true, + }; + + const config = resolveConfig(props); + + expect(config.showVariablesSelector).toBe(true); + }); + + it("should override showLastRefreshedTime when provided in props", () => { + const props: PanelEditorProps = { + pageType: "dashboard", + showLastRefreshedTime: false, + }; + + const config = resolveConfig(props); + + expect(config.showLastRefreshedTime).toBe(false); + }); + + it("should override showOutdatedWarning when provided in props", () => { + const props: PanelEditorProps = { + pageType: "build", + showOutdatedWarning: true, + }; + + const config = resolveConfig(props); + + expect(config.showOutdatedWarning).toBe(true); + // Verify it was originally false in BUILD_PRESET + expect(BUILD_PRESET.showOutdatedWarning).toBe(false); + }); + + it("should override showAddToDashboardButton when provided in props", () => { + const props: PanelEditorProps = { + pageType: "dashboard", + showAddToDashboardButton: true, + }; + + const config = resolveConfig(props); + + expect(config.showAddToDashboardButton).toBe(true); + }); + + it("should override showQueryTypeSelector when provided in props", () => { + const props: PanelEditorProps = { + pageType: "dashboard", + showQueryTypeSelector: true, + }; + + const config = resolveConfig(props); + + expect(config.showQueryTypeSelector).toBe(true); + }); + + it("should override showGeneratedQueryDisplay when provided in props", () => { + const props: PanelEditorProps = { + pageType: "logs", + showGeneratedQueryDisplay: true, + }; + + const config = resolveConfig(props); + + expect(config.showGeneratedQueryDisplay).toBe(true); + }); + + it("should override hideChartPreview when provided in props", () => { + const props: PanelEditorProps = { + pageType: "build", + hideChartPreview: true, + }; + + const config = resolveConfig(props); + + expect(config.hideChartPreview).toBe(true); + }); + + it("should allow multiple overrides at once", () => { + const props: PanelEditorProps = { + pageType: "logs", + showQueryEditor: true, + showQueryBuilder: true, + showAddToDashboardButton: false, + }; + + const config = resolveConfig(props); + + expect(config.showQueryEditor).toBe(true); + expect(config.showQueryBuilder).toBe(true); + expect(config.showAddToDashboardButton).toBe(false); + // Non-overridden values from LOGS_PRESET + expect(config.showVariablesSelector).toBe(false); + expect(config.showLastRefreshedTime).toBe(false); + }); + + it("should respect false overrides (not treat as undefined)", () => { + const props: PanelEditorProps = { + pageType: "dashboard", + showQueryEditor: false, + showQueryBuilder: false, + showVariablesSelector: false, + }; + + const config = resolveConfig(props); + + expect(config.showQueryEditor).toBe(false); + expect(config.showQueryBuilder).toBe(false); + expect(config.showVariablesSelector).toBe(false); + }); + }); + + describe("Chart Type Constants", () => { + describe("ALL_CHART_TYPES", () => { + it("should contain all expected chart types", () => { + expect(ALL_CHART_TYPES).toContain("area"); + expect(ALL_CHART_TYPES).toContain("area-stacked"); + expect(ALL_CHART_TYPES).toContain("bar"); + expect(ALL_CHART_TYPES).toContain("h-bar"); + expect(ALL_CHART_TYPES).toContain("stacked"); + expect(ALL_CHART_TYPES).toContain("h-stacked"); + expect(ALL_CHART_TYPES).toContain("line"); + expect(ALL_CHART_TYPES).toContain("scatter"); + expect(ALL_CHART_TYPES).toContain("pie"); + expect(ALL_CHART_TYPES).toContain("donut"); + expect(ALL_CHART_TYPES).toContain("metric"); + expect(ALL_CHART_TYPES).toContain("gauge"); + expect(ALL_CHART_TYPES).toContain("table"); + expect(ALL_CHART_TYPES).toContain("heatmap"); + expect(ALL_CHART_TYPES).toContain("geomap"); + expect(ALL_CHART_TYPES).toContain("sankey"); + expect(ALL_CHART_TYPES).toContain("html"); + expect(ALL_CHART_TYPES).toContain("markdown"); + expect(ALL_CHART_TYPES).toContain("custom_chart"); + }); + + it("should have 19 chart types", () => { + expect(ALL_CHART_TYPES.length).toBe(19); + }); + }); + + describe("SELECT_STAR_ALLOWED_CHART_TYPES", () => { + it("should only contain table chart type", () => { + expect(SELECT_STAR_ALLOWED_CHART_TYPES).toEqual(["table"]); + }); + + it("should have exactly 1 chart type", () => { + expect(SELECT_STAR_ALLOWED_CHART_TYPES.length).toBe(1); + }); + }); + + describe("AGGREGATION_REQUIRED_CHART_TYPES", () => { + it("should contain chart types that require aggregation", () => { + expect(AGGREGATION_REQUIRED_CHART_TYPES).toContain("area"); + expect(AGGREGATION_REQUIRED_CHART_TYPES).toContain("bar"); + expect(AGGREGATION_REQUIRED_CHART_TYPES).toContain("line"); + expect(AGGREGATION_REQUIRED_CHART_TYPES).toContain("pie"); + expect(AGGREGATION_REQUIRED_CHART_TYPES).toContain("metric"); + expect(AGGREGATION_REQUIRED_CHART_TYPES).toContain("gauge"); + expect(AGGREGATION_REQUIRED_CHART_TYPES).toContain("heatmap"); + expect(AGGREGATION_REQUIRED_CHART_TYPES).toContain("geomap"); + expect(AGGREGATION_REQUIRED_CHART_TYPES).toContain("sankey"); + }); + + it("should not contain table chart type", () => { + expect(AGGREGATION_REQUIRED_CHART_TYPES).not.toContain("table"); + }); + + it("should not contain html chart type", () => { + expect(AGGREGATION_REQUIRED_CHART_TYPES).not.toContain("html"); + }); + + it("should not contain markdown chart type", () => { + expect(AGGREGATION_REQUIRED_CHART_TYPES).not.toContain("markdown"); + }); + + it("should not contain custom_chart type", () => { + expect(AGGREGATION_REQUIRED_CHART_TYPES).not.toContain("custom_chart"); + }); + + it("should have 15 chart types", () => { + expect(AGGREGATION_REQUIRED_CHART_TYPES.length).toBe(15); + }); + }); + + describe("Chart type consistency", () => { + it("SELECT_STAR and AGGREGATION_REQUIRED should be mutually exclusive for regular charts", () => { + const overlap = SELECT_STAR_ALLOWED_CHART_TYPES.filter((type) => + AGGREGATION_REQUIRED_CHART_TYPES.includes(type as any), + ); + expect(overlap).toEqual([]); + }); + + it("all chart types should be accounted for", () => { + const regularChartTypes = ALL_CHART_TYPES.filter( + (type) => !["html", "markdown", "custom_chart"].includes(type), + ); + + regularChartTypes.forEach((type) => { + const inSelectStar = SELECT_STAR_ALLOWED_CHART_TYPES.includes( + type as any, + ); + const inAggregation = AGGREGATION_REQUIRED_CHART_TYPES.includes( + type as any, + ); + expect(inSelectStar || inAggregation).toBe(true); + }); + }); + }); + }); + + describe("Type guards and validation", () => { + it("PanelEditorConfig should have all required properties", () => { + const config: PanelEditorConfig = { + showQueryEditor: true, + showQueryBuilder: true, + showVariablesSelector: true, + showLastRefreshedTime: true, + showOutdatedWarning: true, + showAddToDashboardButton: true, + showQueryTypeSelector: true, + showGeneratedQueryDisplay: true, + hideChartPreview: false, + }; + + // All properties should be booleans + expect(typeof config.showQueryEditor).toBe("boolean"); + expect(typeof config.showQueryBuilder).toBe("boolean"); + expect(typeof config.showVariablesSelector).toBe("boolean"); + expect(typeof config.showLastRefreshedTime).toBe("boolean"); + expect(typeof config.showOutdatedWarning).toBe("boolean"); + expect(typeof config.showAddToDashboardButton).toBe("boolean"); + expect(typeof config.showQueryTypeSelector).toBe("boolean"); + expect(typeof config.showGeneratedQueryDisplay).toBe("boolean"); + expect(typeof config.hideChartPreview).toBe("boolean"); + }); + + it("PanelEditorPageType should accept valid page types", () => { + const validTypes: PanelEditorPageType[] = [ + "dashboard", + "metrics", + "logs", + "build", + ]; + + validTypes.forEach((type) => { + const config = getPresetByPageType(type); + expect(config).toBeDefined(); + }); + }); + }); +}); diff --git a/web/src/components/dashboards/PanelEditor/types/panelEditor.ts b/web/src/components/dashboards/PanelEditor/types/panelEditor.ts new file mode 100644 index 0000000000..14bc75b492 --- /dev/null +++ b/web/src/components/dashboards/PanelEditor/types/panelEditor.ts @@ -0,0 +1,669 @@ +// Copyright 2023 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import type { Ref } from "vue"; + +// ============================================================================ +// Page Type +// ============================================================================ + +/** + * The page context where PanelEditor is being used. + * - 'dashboard': Full-featured panel editor with variables, save/discard + * - 'metrics': PromQL-focused metrics visualization with auto-refresh + * - 'logs': Simplified logs visualization receiving external data + * - 'build': Full-page query builder for logs (like Add Panel) + */ +export type PanelEditorPageType = "dashboard" | "metrics" | "logs" | "build"; + +// ============================================================================ +// DateTime Types +// ============================================================================ + +/** + * Selected date time object passed from parent's DateTimePicker + */ +export interface PanelEditorDateTime { + start_time: number | string; + end_time: number | string; + valueType?: "relative" | "absolute"; + relativeTimePeriod?: string; +} + +// ============================================================================ +// Variables Types +// ============================================================================ + +/** + * Variables data structure for dashboard mode + */ +export interface PanelEditorVariablesData { + isVariablesLoading: boolean; + values: Array<{ + name: string; + value: any; + type?: string; + isLoading?: boolean; + options?: any[]; + isHidden?: boolean; + }>; +} + +// ============================================================================ +// Error Data Types +// ============================================================================ + +/** + * Error data structure for chart errors + */ +export interface PanelEditorErrorData { + errors: string[]; +} + +// ============================================================================ +// Search Response Types (for logs mode) +// ============================================================================ + +/** + * Search response object passed from logs page + */ +export interface PanelEditorSearchResponse { + hits?: any[]; + total?: number; + took?: number; + [key: string]: any; +} + +// ============================================================================ +// Chart Data Types +// ============================================================================ + +/** + * Chart data object that contains panel configuration + */ +export interface PanelEditorChartData { + version?: number; + id?: string; + type?: string; + title?: string; + description?: string; + config?: Record; + htmlContent?: string; + markdownContent?: string; + customChartContent?: string; + customChartResult?: Record; + queryType?: "sql" | "promql"; + queries?: Array<{ + query: string; + vrlFunctionQuery?: string; + customQuery?: boolean; + joins?: any[]; + fields?: { + stream?: string; + stream_type?: string; + x?: any[]; + y?: any[]; + z?: any[]; + breakdown?: any[]; + promql_labels?: any[]; + promql_operations?: any[]; + filter?: any; + latitude?: any; + longitude?: any; + weight?: any; + name?: any; + value_for_maps?: any; + source?: any; + target?: any; + value?: any; + }; + config?: { + promql_legend?: string; + step_value?: number | null; + layer_type?: string; + weight_fixed?: number; + limit?: number; + min?: number; + max?: number; + time_shift?: any[]; + }; + }>; + [key: string]: any; +} + +// ============================================================================ +// Dashboard Data Types (for query builder) +// ============================================================================ + +/** + * Dashboard data structure for dashboard mode + */ +export interface PanelEditorDashboardData { + dashboardId?: string; + title?: string; + description?: string; + tabs?: any[]; + variables?: { + list?: any[]; + }; + defaultDatetimeDuration?: { + type?: "relative" | "absolute"; + startTime?: string; + endTime?: string; + relativeTimePeriod?: string; + }; + [key: string]: any; +} + +// ============================================================================ +// Props Interface +// ============================================================================ + +/** + * PanelEditor component props. + * + * NOTE: Header-related props are NOT included here. + * Each wrapper component (AddPanel, Metrics, Logs) manages its own header. + */ +export interface PanelEditorProps { + // ---- Page Identification ---- + /** + * The page type determines preset configuration and behavior. + * @required + */ + pageType: PanelEditorPageType; + + // ---- Content Configuration ---- + /** + * Whether to show the query editor section at the bottom. + * @default true (dashboard, metrics), false (logs) + */ + showQueryEditor?: boolean; + + /** + * Whether to show the query builder (drag-drop fields). + * @default true (dashboard, metrics), false (logs) + */ + showQueryBuilder?: boolean; + + /** + * Whether to show variables selector above the chart. + * @default true (dashboard), false (metrics, logs) + */ + showVariablesSelector?: boolean; + + /** + * Whether to show the last refreshed timestamp. + * @default true (dashboard, metrics), false (logs) + */ + showLastRefreshedTime?: boolean; + + /** + * Whether to show the "Out of Date" warning when panel data differs from chart data. + * @default true + */ + showOutdatedWarning?: boolean; + + /** + * Whether to show "Add to Dashboard" button inside chart area. + * @default false (dashboard), true (metrics, logs) + */ + showAddToDashboardButton?: boolean; + + // ---- Chart Configuration ---- + /** + * Array of allowed chart types. If not specified, all chart types are allowed. + * Used in logs mode to restrict chart types based on query type. + */ + allowedChartTypes?: string[]; + + // ---- External Data Props (for logs mode) ---- + /** + * External chart data passed from parent (logs visualization). + * When provided, PanelEditor uses this instead of running its own query. + */ + externalChartData?: PanelEditorChartData; + + /** + * Search response data from logs page. + * Used by PanelSchemaRenderer to render charts. + */ + searchResponse?: PanelEditorSearchResponse; + + /** + * Whether this is a UI histogram (logs mode). + * Affects chart rendering and query handling. + */ + isUiHistogram?: boolean; + + /** + * Whether to refresh without using cache (logs mode). + * Triggers cache-busting when running queries. + */ + shouldRefreshWithoutCache?: boolean; + + // ---- DateTime (passed from parent's DateTimePicker) ---- + /** + * Selected date time from parent's DateTimePicker. + * PanelEditor does NOT render DateTimePicker - that stays in wrapper. + */ + selectedDateTime?: PanelEditorDateTime; + + // ---- Variables Data (passed from parent's VariablesSelector) ---- + /** + * Variables data for query execution (dashboard mode). + * PanelEditor does NOT manage variables - that stays in wrapper for dashboard. + */ + variablesData?: PanelEditorVariablesData; + + // ---- Dashboard Data (for query builder) ---- + /** + * Dashboard data for query builder context (dashboard mode). + * Contains dashboard configuration, tabs, and variables definitions. + */ + dashboardData?: PanelEditorDashboardData; + + // ---- Mode Flags ---- + /** + * Whether the panel is in edit mode (editing existing panel vs creating new). + * @default false + */ + editMode?: boolean; + + // ---- Build Mode Options ---- + /** + * Whether to show SQL/PromQL and Builder/Custom mode toggle. + * @default false (dashboard, metrics, logs), true (build) + */ + showQueryTypeSelector?: boolean; + + /** + * Whether to show the generated SQL display section. + * @default false (dashboard, metrics, logs), true (build) + */ + showGeneratedQueryDisplay?: boolean; + + /** + * Whether to hide the chart preview area. + * @default false (dashboard, metrics, logs), true (build) + */ + hideChartPreview?: boolean; +} + +// ============================================================================ +// Emits Interface +// ============================================================================ + +/** + * Events emitted by PanelEditor component. + */ +export interface PanelEditorEmits { + /** + * Emitted when variables data is updated (dashboard mode). + * Parent should update its variablesData and sync to URL. + */ + (e: "variablesDataUpdated", data: PanelEditorVariablesData): void; + + /** + * Emitted when "Add Variable" is clicked (dashboard mode). + * Parent should open the AddSettingVariable drawer. + */ + (e: "openAddVariable"): void; + + /** + * Emitted when "Add to Dashboard" is clicked (metrics, logs mode). + * Parent should open the AddToDashboard dialog. + */ + (e: "addToDashboard"): void; + + /** + * Emitted when chart API returns an error. + * Parent can handle the error (e.g., show notification). + */ + (e: "chartApiError", error: any): void; + + /** + * Emitted when user zooms on chart (for datetime update). + * Parent should update its DateTimePicker with new time range. + */ + (e: "dataZoom", event: { start: number; end: number }): void; + + /** + * Emitted when chart data changes (for parent to track). + */ + (e: "chartDataUpdated", data: PanelEditorChartData): void; + + /** + * Emitted when metadata is updated (for QueryInspector in dashboard mode). + */ + (e: "metaDataUpdated", metadata: any): void; + + /** + * Emitted when SQL query is auto-generated from builder fields. + * Useful for syncing query to parent (e.g., logs SearchBar). + */ + (e: "queryGenerated", query: string): void; + + /** + * Emitted when customQuery mode changes. + * true = custom mode (user edits query), false = builder mode (auto-generated). + */ + (e: "customQueryModeChanged", isCustomMode: boolean): void; + + /** + * Emitted when search request trace IDs change. + * Used for cancel query functionality - parent can sync these to its own loading state. + */ + (e: "searchRequestTraceIdsUpdated", traceIds: string[]): void; + + /** + * Emitted when initial variable values are parsed from URL query params. + * Parent can use these to initialize variables from URL. + */ + (e: "initialVariableValuesUpdated", values: Record): void; +} + +// ============================================================================ +// Expose Interface +// ============================================================================ + +/** + * Methods and properties exposed by PanelEditor via ref. + * Parent components access these via templateRef.value. + */ +export interface PanelEditorExpose { + // ---- Actions ---- + /** + * Run the chart query. + * @param withoutCache - If true, bypass cache (enterprise feature) + */ + runQuery: (withoutCache?: boolean) => void; + + /** + * Reset error state. + */ + resetErrors: () => void; + + /** + * Collapse or expand the field list sidebar. + */ + collapseFieldList: () => void; + + /** + * Update the datetime for queries. + * @param dateTime - The new datetime object + */ + updateDateTime: (dateTime: PanelEditorDateTime) => void; + + // ---- State Refs ---- + /** + * Current chart data (result of query execution). + */ + chartData: Ref; + + /** + * Error data from chart queries. + */ + errorData: PanelEditorErrorData; + + /** + * Metadata from query execution (for QueryInspector). + */ + metaData: Ref; + + /** + * Series data from chart rendering. + */ + seriesData: Ref; + + /** + * Timestamp of last query execution. + */ + lastTriggeredAt: Ref; + + // ---- Panel Data (from useDashboardPanelData) ---- + /** + * Dashboard panel data object (reactive). + * Contains panel configuration, queries, layout, etc. + */ + dashboardPanelData: any; + + /** + * Whether the chart is out of date (panel data differs from chart data). + */ + isOutDated: Ref; + + // ---- Loading State ---- + /** + * Whether queries are currently running. + */ + isLoading: Ref; + + /** + * Array of search request trace IDs (for cancel functionality). + */ + searchRequestTraceIds: Ref; + + // ---- Warning Messages ---- + /** + * Max query range warning message. + */ + maxQueryRangeWarning: Ref; + + /** + * Series limit warning message. + */ + limitNumberOfSeriesWarningMessage: Ref; + + /** + * General error message. + */ + errorMessage: Ref; +} + +// ============================================================================ +// Preset Configurations +// ============================================================================ + +/** + * Configuration options for PanelEditor. + * These are the resolved values after merging props with presets. + */ +export interface PanelEditorConfig { + showQueryEditor: boolean; + showQueryBuilder: boolean; + showVariablesSelector: boolean; + showLastRefreshedTime: boolean; + showOutdatedWarning: boolean; + showAddToDashboardButton: boolean; + /** Whether to show SQL/PromQL and Builder/Custom mode toggle (build mode) */ + showQueryTypeSelector: boolean; + /** Whether to show the generated SQL display section (build mode) */ + showGeneratedQueryDisplay: boolean; + /** Whether to hide the chart preview area (build mode) */ + hideChartPreview: boolean; +} + +/** + * Preset configuration for dashboard page. + * Full-featured panel editor with variables support. + */ +export const DASHBOARD_PRESET: PanelEditorConfig = { + showQueryEditor: true, + showQueryBuilder: true, + showVariablesSelector: true, + showLastRefreshedTime: true, + showOutdatedWarning: true, + showAddToDashboardButton: false, // Dashboard has Save button instead + showQueryTypeSelector: false, + showGeneratedQueryDisplay: false, + hideChartPreview: false, +}; + +/** + * Preset configuration for metrics page. + * PromQL-focused with auto-refresh support. + */ +export const METRICS_PRESET: PanelEditorConfig = { + showQueryEditor: true, + showQueryBuilder: true, + showVariablesSelector: false, + showLastRefreshedTime: true, + showOutdatedWarning: true, + showAddToDashboardButton: true, // Show inside chart area + showQueryTypeSelector: false, + showGeneratedQueryDisplay: false, + hideChartPreview: false, +}; + +/** + * Preset configuration for logs visualization page. + * Simplified - no query editor, receives external data. + */ +export const LOGS_PRESET: PanelEditorConfig = { + showQueryEditor: false, // No query editor in visualization + showQueryBuilder: false, // No query builder in visualization + showVariablesSelector: false, + showLastRefreshedTime: false, + showOutdatedWarning: true, + showAddToDashboardButton: true, // Show inside chart area + showQueryTypeSelector: false, + showGeneratedQueryDisplay: false, + hideChartPreview: false, +}; + +/** + * Preset configuration for build query page. + * Full-page query builder for logs (like Add Panel). + */ +export const BUILD_PRESET: PanelEditorConfig = { + showQueryEditor: false, // Uses GeneratedQueryDisplay instead + showQueryBuilder: true, // Show X/Y/Breakdown builder + showVariablesSelector: false, + showLastRefreshedTime: false, + showOutdatedWarning: false, + showAddToDashboardButton: true, // Show Add to Dashboard button + showQueryTypeSelector: true, // Show SQL/PromQL and Builder/Custom toggle + showGeneratedQueryDisplay: true, // Show generated SQL section + hideChartPreview: false, // Show chart preview (like Add Panel) +}; + +/** + * Get preset configuration by page type. + * @param pageType - The page type + * @returns The preset configuration + */ +export function getPresetByPageType( + pageType: PanelEditorPageType, +): PanelEditorConfig { + switch (pageType) { + case "dashboard": + return DASHBOARD_PRESET; + case "metrics": + return METRICS_PRESET; + case "logs": + return LOGS_PRESET; + case "build": + return BUILD_PRESET; + default: + return DASHBOARD_PRESET; + } +} + +/** + * Merge props with preset configuration. + * Props take precedence over preset defaults. + * @param props - Component props + * @returns Resolved configuration + */ +export function resolveConfig(props: PanelEditorProps): PanelEditorConfig { + const preset = getPresetByPageType(props.pageType); + + return { + showQueryEditor: props.showQueryEditor ?? preset.showQueryEditor, + showQueryBuilder: props.showQueryBuilder ?? preset.showQueryBuilder, + showVariablesSelector: + props.showVariablesSelector ?? preset.showVariablesSelector, + showLastRefreshedTime: + props.showLastRefreshedTime ?? preset.showLastRefreshedTime, + showOutdatedWarning: + props.showOutdatedWarning ?? preset.showOutdatedWarning, + showAddToDashboardButton: + props.showAddToDashboardButton ?? preset.showAddToDashboardButton, + showQueryTypeSelector: + props.showQueryTypeSelector ?? preset.showQueryTypeSelector, + showGeneratedQueryDisplay: + props.showGeneratedQueryDisplay ?? preset.showGeneratedQueryDisplay, + hideChartPreview: props.hideChartPreview ?? preset.hideChartPreview, + }; +} + +// ============================================================================ +// Chart Types (for allowedChartTypes prop) +// ============================================================================ + +/** + * All available chart types. + */ +export const ALL_CHART_TYPES = [ + "area", + "area-stacked", + "bar", + "h-bar", + "stacked", + "h-stacked", + "line", + "scatter", + "pie", + "donut", + "metric", + "gauge", + "table", + "heatmap", + "geomap", + "sankey", + "html", + "markdown", + "custom_chart", +] as const; + +export type ChartType = (typeof ALL_CHART_TYPES)[number]; + +/** + * Chart types allowed when query contains SELECT * (logs visualization). + * These charts can render without aggregation. + */ +export const SELECT_STAR_ALLOWED_CHART_TYPES: ChartType[] = ["table"]; + +/** + * Chart types that require aggregation (cannot use SELECT *). + */ +export const AGGREGATION_REQUIRED_CHART_TYPES: ChartType[] = [ + "area", + "area-stacked", + "bar", + "h-bar", + "stacked", + "h-stacked", + "line", + "scatter", + "pie", + "donut", + "metric", + "gauge", + "heatmap", + "geomap", + "sankey", +]; diff --git a/web/src/components/dashboards/addPanel/DashboardQueryBuilder.vue b/web/src/components/dashboards/addPanel/DashboardQueryBuilder.vue index 3f27babc1c..e115a7401a 100644 --- a/web/src/components/dashboards/addPanel/DashboardQueryBuilder.vue +++ b/web/src/components/dashboards/addPanel/DashboardQueryBuilder.vue @@ -742,6 +742,7 @@ export default defineComponent({ PromQLBuilderOptions, }, props: ["dashboardData"], + emits: ["customChartTemplateSelected"], setup(props) { const showXAxis = ref(true); const panelName = ref(""); @@ -965,8 +966,16 @@ export default defineComponent({ return; } + // In custom query mode, use the field's alias/column (which matches the SQL column name) + // instead of the raw field name from args, since custom mode fields represent SQL result columns + const isCustomQuery = dashboardPanelData.data.queries[ + dashboardPanelData.layout.currentQueryIndex + ].customQuery; + const fieldObj = { - name: firstFieldTypeArg.field, + name: isCustomQuery + ? (dragElement?.alias || firstFieldTypeArg.field) + : firstFieldTypeArg.field, streamAlias: firstFieldTypeArg.streamAlias, }; diff --git a/web/src/composables/dashboard/usePanelDataLoader.spec.ts b/web/src/composables/dashboard/usePanelDataLoader.spec.ts index c97b70ff51..ac02adf986 100644 --- a/web/src/composables/dashboard/usePanelDataLoader.spec.ts +++ b/web/src/composables/dashboard/usePanelDataLoader.spec.ts @@ -71,7 +71,9 @@ vi.mock("@/utils/zincutils", () => ({ })), isWebSocketEnabled: vi.fn(() => false), isStreamingEnabled: vi.fn(() => false), - escapeSingleQuotes: vi.fn((str) => str.replace(/\\/g, "\\\\").replace(/'/g, "\\'")), + escapeSingleQuotes: vi.fn((str) => + str.replace(/\\/g, "\\\\").replace(/'/g, "\\'"), + ), useLocalWrapContent: vi.fn(() => null), })); @@ -316,7 +318,7 @@ describe("usePanelDataLoader", () => { // Mock setTimeout to work with promise-based async flows vi.spyOn(global, "setTimeout").mockImplementation((fn: any, ms: number) => { - if (typeof fn === 'function') { + if (typeof fn === "function") { // Execute immediately for tests Promise.resolve().then(() => fn()); } @@ -327,7 +329,7 @@ describe("usePanelDataLoader", () => { vi.spyOn(global, "clearTimeout").mockImplementation(() => {}); // Mock AbortController - global.AbortController = vi.fn(function() { + global.AbortController = vi.fn(function () { return { signal: { addEventListener: vi.fn(), @@ -338,9 +340,9 @@ describe("usePanelDataLoader", () => { }; }) as any; - // Mock window.addEventListener and removeEventListener - vi.spyOn(window, 'addEventListener').mockImplementation(() => {}); - vi.spyOn(window, 'removeEventListener').mockImplementation(() => {}); + // Mock window.addEventListener and removeEventListener + vi.spyOn(window, "addEventListener").mockImplementation(() => {}); + vi.spyOn(window, "removeEventListener").mockImplementation(() => {}); }); afterEach(() => { @@ -1101,9 +1103,9 @@ describe("usePanelDataLoader", () => { ref(null), ); - // Start the load operation + // Start the load operation const loadPromise = loader.loadData(); - + // Check loading state immediately (should be true if set synchronously) // Or we can just verify the final state is false await loadPromise; @@ -1144,9 +1146,8 @@ describe("usePanelDataLoader", () => { const variablesData = createMockVariablesData(); // Mock streaming enabled, WebSocket disabled - const { isWebSocketEnabled, isStreamingEnabled } = await import( - "@/utils/zincutils" - ); + const { isWebSocketEnabled, isStreamingEnabled } = + await import("@/utils/zincutils"); (isWebSocketEnabled as any).mockReturnValue(false); (isStreamingEnabled as any).mockReturnValue(true); @@ -1228,9 +1229,7 @@ describe("usePanelDataLoader", () => { }, }; - mockSearchResults = [ - { timestamp: Date.now(), value: 100 } - ]; + mockSearchResults = [{ timestamp: Date.now(), value: 100 }]; const loader = usePanelDataLoader( panelSchema, @@ -1343,8 +1342,8 @@ describe("usePanelDataLoader", () => { // Check that some error state was set (either errorDetail has content or data is empty) expect( - loader.errorDetail.value.message.length > 0 || - loader.data.value.length === 0 + loader.errorDetail.value.message.length > 0 || + loader.data.value.length === 0, ).toBe(true); }); }); @@ -1356,9 +1355,8 @@ describe("usePanelDataLoader", () => { const variablesData = createMockVariablesData(); // Enable WebSocket - const { isWebSocketEnabled, isStreamingEnabled } = await import( - "@/utils/zincutils" - ); + const { isWebSocketEnabled, isStreamingEnabled } = + await import("@/utils/zincutils"); (isWebSocketEnabled as any).mockReturnValue(true); (isStreamingEnabled as any).mockReturnValue(false); @@ -1385,9 +1383,8 @@ describe("usePanelDataLoader", () => { const variablesData = createMockVariablesData(); // Enable WebSocket with error - const { isWebSocketEnabled, isStreamingEnabled } = await import( - "@/utils/zincutils" - ); + const { isWebSocketEnabled, isStreamingEnabled } = + await import("@/utils/zincutils"); (isWebSocketEnabled as any).mockReturnValue(true); (isStreamingEnabled as any).mockReturnValue(false); shouldWebSocketThrow = true; @@ -1417,9 +1414,8 @@ describe("usePanelDataLoader", () => { const variablesData = createMockVariablesData(); // Enable streaming, disable WebSocket - const { isWebSocketEnabled, isStreamingEnabled } = await import( - "@/utils/zincutils" - ); + const { isWebSocketEnabled, isStreamingEnabled } = + await import("@/utils/zincutils"); (isWebSocketEnabled as any).mockReturnValue(false); (isStreamingEnabled as any).mockReturnValue(true); @@ -1446,9 +1442,8 @@ describe("usePanelDataLoader", () => { const variablesData = createMockVariablesData(); // Enable streaming with error - const { isWebSocketEnabled, isStreamingEnabled } = await import( - "@/utils/zincutils" - ); + const { isWebSocketEnabled, isStreamingEnabled } = + await import("@/utils/zincutils"); (isWebSocketEnabled as any).mockReturnValue(false); (isStreamingEnabled as any).mockReturnValue(true); shouldStreamingThrow = true; @@ -1581,7 +1576,9 @@ describe("usePanelDataLoader", () => { await loader.loadData(); - expect(loader.isCachedDataDifferWithCurrentTimeRange.value).toBeDefined(); + expect( + loader.isCachedDataDifferWithCurrentTimeRange.value, + ).toBeDefined(); }); it("should handle cache retrieval errors", async () => { @@ -1690,7 +1687,7 @@ describe("usePanelDataLoader", () => { const loadPromise = loader.loadData(); // Give it a moment to attempt cache restore (happens before visibility wait) - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise((resolve) => setTimeout(resolve, 100)); // With forceLoad=false and runCount=0, cache restore is attempted (line 803) // Cache operation count should be > 0 because getPanelCache was called @@ -1817,7 +1814,8 @@ describe("usePanelDataLoader", () => { const panelSchema = createMockPanelSchema({ queries: [ { - query: "SELECT * FROM logs WHERE time >= now() - INTERVAL '$__range'", + query: + "SELECT * FROM logs WHERE time >= now() - INTERVAL '$__range'", fields: { stream_type: "logs" }, }, ], @@ -1853,7 +1851,8 @@ describe("usePanelDataLoader", () => { const panelSchema = createMockPanelSchema({ queries: [ { - query: "SELECT * FROM logs WHERE time >= now() - INTERVAL '${__range}'", + query: + "SELECT * FROM logs WHERE time >= now() - INTERVAL '${__range}'", fields: { stream_type: "logs" }, }, ], @@ -1888,7 +1887,8 @@ describe("usePanelDataLoader", () => { const panelSchema = createMockPanelSchema({ queries: [ { - query: "SELECT * FROM logs WHERE time >= now() - INTERVAL '$__range'", + query: + "SELECT * FROM logs WHERE time >= now() - INTERVAL '$__range'", fields: { stream_type: "logs" }, }, ], @@ -1978,7 +1978,8 @@ describe("usePanelDataLoader", () => { const panelSchema = createMockPanelSchema({ queries: [ { - query: "SELECT * FROM logs WHERE service IN (${services:singlequote})", + query: + "SELECT * FROM logs WHERE service IN (${services:singlequote})", fields: { stream_type: "logs" }, }, ], @@ -2120,7 +2121,8 @@ describe("usePanelDataLoader", () => { const panelSchema = createMockPanelSchema({ queries: [ { - query: "SELECT * FROM logs WHERE service IN (${services:doublequote})", + query: + "SELECT * FROM logs WHERE service IN (${services:doublequote})", fields: { stream_type: "logs" }, }, ], @@ -2429,7 +2431,8 @@ describe("usePanelDataLoader", () => { const panelSchema = createMockPanelSchema({ queries: [ { - query: "SELECT * FROM logs WHERE service IN (${services:singlequote})", + query: + "SELECT * FROM logs WHERE service IN (${services:singlequote})", fields: { stream_type: "logs" }, }, ], @@ -2778,9 +2781,8 @@ describe("usePanelDataLoader", () => { const variablesData = createMockVariablesData(); // Enable WebSocket and simulate error - const { isWebSocketEnabled, isStreamingEnabled } = await import( - "@/utils/zincutils" - ); + const { isWebSocketEnabled, isStreamingEnabled } = + await import("@/utils/zincutils"); (isWebSocketEnabled as any).mockReturnValue(true); (isStreamingEnabled as any).mockReturnValue(false); @@ -2834,10 +2836,10 @@ describe("usePanelDataLoader", () => { // Simulate abortion const loadPromise = loader.loadData(); - + // Wait a bit then check the state await loadPromise; - + expect(loader.loading.value).toBe(false); }); @@ -2847,7 +2849,7 @@ describe("usePanelDataLoader", () => { const variablesData = createMockVariablesData(); // Mock AbortController to throw abort error - global.AbortController = vi.fn(function() { + global.AbortController = vi.fn(function () { return { signal: { addEventListener: vi.fn(), @@ -2891,7 +2893,8 @@ describe("usePanelDataLoader", () => { queryType: "sql", queries: [ { - query: "SELECT * FROM logs WHERE service = $service AND time >= $__interval_ms", + query: + "SELECT * FROM logs WHERE service = $service AND time >= $__interval_ms", fields: { stream_type: "logs", x: [{ alias: "timestamp" }], @@ -2904,8 +2907,13 @@ describe("usePanelDataLoader", () => { ], }); - mockSearchResults = { hits: [[{ test: "data" }]], per_query_response: true }; - mockAnnotations = [{ id: "test", title: "Test Annotation", time: Date.now() }]; + mockSearchResults = { + hits: [[{ test: "data" }]], + per_query_response: true, + }; + mockAnnotations = [ + { id: "test", title: "Test Annotation", time: Date.now() }, + ]; const loader = usePanelDataLoader( panelSchema, diff --git a/web/src/composables/dashboard/usePanelDataLoader.ts b/web/src/composables/dashboard/usePanelDataLoader.ts index 23133a8c01..b0c6918ca3 100644 --- a/web/src/composables/dashboard/usePanelDataLoader.ts +++ b/web/src/composables/dashboard/usePanelDataLoader.ts @@ -156,11 +156,13 @@ export const usePanelDataLoader = ( }, metadata: { queries: [] as any, - seriesLimiting: undefined as { - totalMetricsReceived: number; - metricsStored: number; - maxSeries: number; - } | undefined, + seriesLimiting: undefined as + | { + totalMetricsReceived: number; + metricsStored: number; + maxSeries: number; + } + | undefined, } as { queries: any; seriesLimiting?: { @@ -997,7 +999,8 @@ export const usePanelDataLoader = ( } // Get series limit from config - const maxSeries = store.state?.zoConfig?.max_dashboard_series ?? 100; + const maxSeries = + store.state?.zoConfig?.max_dashboard_series ?? 100; // Create chunk processor for efficient metric merging const chunkProcessor = createPromQLChunkProcessor({ @@ -1017,7 +1020,7 @@ export const usePanelDataLoader = ( // Process chunk using extracted processor module queryResults[queryIndex] = chunkProcessor.processChunk( queryResults[queryIndex], - newData + newData, ); // Update state with accumulated results @@ -1945,7 +1948,6 @@ export const usePanelDataLoader = ( const hasAtLeastOneQuery = () => panelSchema.value.queries?.some((q: any) => q?.query); - // [START] variables management // check when the variables data changes diff --git a/web/src/composables/useLogs/logsUtils.spec.ts b/web/src/composables/useLogs/logsUtils.spec.ts new file mode 100644 index 0000000000..2dfc83a461 --- /dev/null +++ b/web/src/composables/useLogs/logsUtils.spec.ts @@ -0,0 +1,763 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ref, reactive } from "vue"; + +// Create mock searchObj that will be used by logsUtils +const mockSearchObj = reactive({ + data: { + query: "", + datetime: { + type: "relative", + startTime: new Date().getTime(), + endTime: new Date().getTime(), + relativeTimePeriod: "15m", + }, + stream: { + streamType: "logs", + selectedStream: ["default"], + selectedFields: [], + }, + queryResults: { + hits: [], + is_histogram_eligible: true, + }, + tempFunctionContent: "", + transformType: "", + selectedTransform: null, + searchRequestTraceIds: [] as string[], + }, + meta: { + sqlMode: true, + showTransformEditor: false, + pageType: "logs", + showHistogram: true, + quickMode: true, + refreshInterval: 0, + useUserDefinedSchemas: false, + regions: [], + clusters: [], + logsVisualizeToggle: "logs", + }, + organizationIdentifier: "default", + communicationMethod: "http", +}); + +// Mock searchState +vi.mock("@/composables/useLogs/searchState", () => ({ + searchState: () => ({ + searchObj: mockSearchObj, + }), +})); + +// Mock logsVisualization +vi.mock("@/composables/useLogs/logsVisualization", () => ({ + encodeVisualizationConfig: vi.fn().mockReturnValue("encoded"), + getVisualizationConfig: vi.fn().mockReturnValue({}), + encodeBuildConfig: vi.fn().mockReturnValue("encoded"), + getBuildConfig: vi.fn().mockReturnValue({}), +})); + +// Mock vuex store +const mockStore = { + state: { + selectedOrganization: { + identifier: "test-org", + }, + zoConfig: { + timestamp_column: "_timestamp", + actions_enabled: false, + super_cluster_enabled: false, + }, + theme: "light", + }, +}; + +vi.mock("vuex", () => ({ + useStore: () => mockStore, +})); + +// Mock quasar +const mockNotify = vi.fn(); +vi.mock("quasar", () => ({ + useQuasar: () => ({ + notify: mockNotify, + }), +})); + +// Mock vue-router +const mockRouterPush = vi.fn(); +const mockCurrentRoute = ref({ + query: {}, +}); + +vi.mock("vue-router", () => ({ + useRouter: () => ({ + push: mockRouterPush, + currentRoute: mockCurrentRoute, + }), +})); + +// Mock aws-exports +vi.mock("@/aws-exports", () => ({ + default: { + isEnterprise: "false", + isCloud: "false", + }, +})); + +// Mock zincutils +vi.mock("@/utils/zincutils", () => ({ + b64EncodeUnicode: vi.fn((str) => Buffer.from(str).toString("base64")), + useLocalLogFilterField: vi.fn().mockReturnValue({ value: {} }), +})); + +// Mock constants +vi.mock("@/utils/logs/constants", () => ({ + TIME_MULTIPLIERS: { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, + }, +})); + +// Mock node-sql-parser +const mockParserInstance = { + astify: vi.fn(), + sqlify: vi.fn(), +}; + +vi.mock("@openobserve/node-sql-parser/build/datafusionsql", () => ({ + Parser: class MockParser { + astify = mockParserInstance.astify; + sqlify = mockParserInstance.sqlify; + }, +})); + +// Import the module under test (after mocks are set up) +import { logsUtils } from "./logsUtils"; + +describe("logsUtils", () => { + let utils: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset mockSearchObj + mockSearchObj.data.query = ""; + mockSearchObj.data.searchRequestTraceIds = []; + mockSearchObj.meta.sqlMode = true; + // Get fresh instance of logsUtils + utils = logsUtils(); + }); + + describe("fnParsedSQL", () => { + it("should parse a valid SQL query", () => { + const mockAst = { + columns: [{ expr: { type: "column", column: "field1" } }], + from: [{ table: "logs" }], + where: null, + groupby: null, + orderby: null, + limit: null, + }; + mockParserInstance.astify.mockReturnValue(mockAst); + + const result = utils.fnParsedSQL("SELECT field1 FROM logs"); + expect(mockParserInstance.astify).toHaveBeenCalled(); + expect(result).toEqual(mockAst); + }); + + it("should return default result when parser throws error", () => { + mockParserInstance.astify.mockImplementation(() => { + throw new Error("Parse error"); + }); + + const result = utils.fnParsedSQL("INVALID SQL"); + expect(result).toEqual({ + columns: [], + from: [], + orderby: null, + limit: null, + groupby: null, + where: null, + }); + }); + + it("should filter out comment lines starting with --", () => { + const mockAst = { columns: [], from: [], orderby: null, limit: null, groupby: null, where: null }; + mockParserInstance.astify.mockReturnValue(mockAst); + + utils.fnParsedSQL("-- this is a comment\nSELECT * FROM logs"); + + // The call should have the comment filtered out + expect(mockParserInstance.astify).toHaveBeenCalledWith("SELECT * FROM logs"); + }); + + it("should use searchObj.data.query when no query provided", () => { + const mockAst = { columns: [], from: [], orderby: null, limit: null, groupby: null, where: null }; + mockParserInstance.astify.mockReturnValue(mockAst); + mockSearchObj.data.query = "SELECT * FROM default_stream"; + + utils.fnParsedSQL(""); + + expect(mockParserInstance.astify).toHaveBeenCalledWith("SELECT * FROM default_stream"); + }); + }); + + describe("fnUnparsedSQL", () => { + it("should convert AST back to SQL string", () => { + mockParserInstance.sqlify.mockReturnValue("SELECT * FROM logs"); + + const result = utils.fnUnparsedSQL({ columns: [], from: [] }); + expect(result).toBe("SELECT * FROM logs"); + }); + + it("should return empty string when sqlify throws error", () => { + mockParserInstance.sqlify.mockImplementation(() => { + throw new Error("Sqlify error"); + }); + + const result = utils.fnUnparsedSQL({ invalid: "object" }); + expect(result).toBe(""); + }); + + it("should return empty string when sqlify returns undefined", () => { + mockParserInstance.sqlify.mockReturnValue(undefined); + + const result = utils.fnUnparsedSQL({}); + expect(result).toBe(""); + }); + }); + + describe("extractTimestamps", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should extract timestamps for seconds (s)", () => { + const result = utils.extractTimestamps("30s"); + expect(result).toBeDefined(); + expect(result!.to).toBe(new Date("2024-06-15T12:00:00.000Z").getTime()); + expect(result!.from).toBe(new Date("2024-06-15T12:00:00.000Z").getTime() - 30 * 1000); + }); + + it("should extract timestamps for minutes (m)", () => { + const result = utils.extractTimestamps("5m"); + expect(result).toBeDefined(); + expect(result!.to - result!.from).toBe(5 * 60 * 1000); + }); + + it("should extract timestamps for hours (h)", () => { + const result = utils.extractTimestamps("2h"); + expect(result).toBeDefined(); + expect(result!.to - result!.from).toBe(2 * 60 * 60 * 1000); + }); + + it("should extract timestamps for days (d)", () => { + const result = utils.extractTimestamps("1d"); + expect(result).toBeDefined(); + expect(result!.to - result!.from).toBe(24 * 60 * 60 * 1000); + }); + + it("should extract timestamps for weeks (w)", () => { + const result = utils.extractTimestamps("1w"); + expect(result).toBeDefined(); + expect(result!.to - result!.from).toBe(7 * 24 * 60 * 60 * 1000); + }); + + it("should extract timestamps for months (M)", () => { + const result = utils.extractTimestamps("1M"); + expect(result).toBeDefined(); + // Month calculation is variable, just check it's a valid range + expect(result!.from).toBeLessThan(result!.to); + }); + + it("should return undefined for invalid period format", () => { + const result = utils.extractTimestamps("5x"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for empty string", () => { + const result = utils.extractTimestamps(""); + expect(result).toBeUndefined(); + }); + + it("should return undefined for null/undefined", () => { + const result = utils.extractTimestamps(null as any); + expect(result).toBeUndefined(); + }); + + it("should return undefined for negative values", () => { + const result = utils.extractTimestamps("-5m"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for zero value", () => { + const result = utils.extractTimestamps("0m"); + expect(result).toBeUndefined(); + }); + + it("should return undefined for non-numeric value", () => { + const result = utils.extractTimestamps("abcm"); + expect(result).toBeUndefined(); + }); + }); + + describe("hasAggregation", () => { + it("should return true when column has aggr_func type", () => { + const columns = [ + { expr: { type: "aggr_func", name: "count" } }, + ]; + expect(utils.hasAggregation(columns)).toBe(true); + }); + + it("should return false when columns have no aggregation", () => { + const columns = [ + { expr: { type: "column", column: "field1" } }, + { expr: { type: "column", column: "field2" } }, + ]; + expect(utils.hasAggregation(columns)).toBe(false); + }); + + it("should return true when query contains GROUP BY", () => { + mockSearchObj.data.query = "SELECT field1, COUNT(*) FROM logs GROUP BY field1"; + const columns = [{ expr: { type: "column" } }]; + expect(utils.hasAggregation(columns)).toBe(true); + }); + + it("should return false for empty columns array", () => { + mockSearchObj.data.query = ""; + expect(utils.hasAggregation([])).toBe(false); + }); + + it("should handle null/undefined columns", () => { + mockSearchObj.data.query = ""; + expect(utils.hasAggregation(null as any)).toBe(false); + expect(utils.hasAggregation(undefined as any)).toBe(false); + }); + }); + + describe("isLimitQuery", () => { + it("should return true when LIMIT is present with values", () => { + const parsedSQL = { + limit: { + value: [{ type: "number", value: 10 }], + }, + }; + expect(utils.isLimitQuery(parsedSQL as any)).toBe(true); + }); + + it("should return false when LIMIT is not present", () => { + const parsedSQL = { limit: null }; + expect(utils.isLimitQuery(parsedSQL as any)).toBe(false); + }); + + it("should return false when limit value is empty array", () => { + const parsedSQL = { limit: { value: [] } }; + expect(utils.isLimitQuery(parsedSQL as any)).toBe(false); + }); + + it("should return false for null input", () => { + expect(utils.isLimitQuery(null)).toBe(false); + }); + + it("should return false for undefined input", () => { + expect(utils.isLimitQuery(undefined as any)).toBe(false); + }); + }); + + describe("isDistinctQuery", () => { + it("should return true when DISTINCT is present", () => { + const parsedSQL = { + distinct: { type: "DISTINCT" }, + }; + expect(utils.isDistinctQuery(parsedSQL)).toBe(true); + }); + + it("should return false when DISTINCT is not present", () => { + const parsedSQL = { distinct: null }; + expect(utils.isDistinctQuery(parsedSQL)).toBe(false); + }); + + it("should return false for null input", () => { + expect(utils.isDistinctQuery(null)).toBe(false); + }); + + it("should return false when distinct has different type", () => { + const parsedSQL = { distinct: { type: "ALL" } }; + expect(utils.isDistinctQuery(parsedSQL)).toBe(false); + }); + }); + + describe("isWithQuery", () => { + it("should return true when WITH clause is present", () => { + const parsedSQL = { + with: [{ name: "cte", expr: {} }], + }; + expect(utils.isWithQuery(parsedSQL)).toBe(true); + }); + + it("should return false when WITH is not present", () => { + const parsedSQL = { with: null }; + expect(utils.isWithQuery(parsedSQL)).toBe(false); + }); + + it("should return false when WITH is empty array", () => { + const parsedSQL = { with: [] }; + expect(utils.isWithQuery(parsedSQL)).toBe(false); + }); + + it("should return false for null input", () => { + expect(utils.isWithQuery(null)).toBe(false); + }); + }); + + describe("addTraceId", () => { + beforeEach(() => { + mockSearchObj.data.searchRequestTraceIds = []; + }); + + it("should add trace ID to the collection", () => { + utils.addTraceId("trace-123"); + expect(mockSearchObj.data.searchRequestTraceIds).toContain("trace-123"); + }); + + it("should not add duplicate trace ID", () => { + mockSearchObj.data.searchRequestTraceIds = ["trace-123"]; + utils.addTraceId("trace-123"); + expect(mockSearchObj.data.searchRequestTraceIds).toEqual(["trace-123"]); + }); + + it("should add multiple unique trace IDs", () => { + utils.addTraceId("trace-1"); + utils.addTraceId("trace-2"); + utils.addTraceId("trace-3"); + expect(mockSearchObj.data.searchRequestTraceIds).toEqual([ + "trace-1", + "trace-2", + "trace-3", + ]); + }); + + it("should not add empty string trace ID", () => { + utils.addTraceId(""); + expect(mockSearchObj.data.searchRequestTraceIds).toEqual([]); + }); + + it("should not add null/undefined trace ID", () => { + utils.addTraceId(null as any); + utils.addTraceId(undefined as any); + expect(mockSearchObj.data.searchRequestTraceIds).toEqual([]); + }); + }); + + describe("removeTraceId", () => { + beforeEach(() => { + mockSearchObj.data.searchRequestTraceIds = ["trace-1", "trace-2", "trace-3"]; + }); + + it("should remove trace ID from the collection", () => { + utils.removeTraceId("trace-2"); + expect(mockSearchObj.data.searchRequestTraceIds).toEqual([ + "trace-1", + "trace-3", + ]); + }); + + it("should handle removing non-existent trace ID", () => { + utils.removeTraceId("trace-999"); + expect(mockSearchObj.data.searchRequestTraceIds).toEqual([ + "trace-1", + "trace-2", + "trace-3", + ]); + }); + + it("should handle empty collection", () => { + mockSearchObj.data.searchRequestTraceIds = []; + utils.removeTraceId("trace-1"); + expect(mockSearchObj.data.searchRequestTraceIds).toEqual([]); + }); + + it("should not throw for empty string", () => { + expect(() => utils.removeTraceId("")).not.toThrow(); + }); + + it("should not throw for null/undefined", () => { + expect(() => utils.removeTraceId(null as any)).not.toThrow(); + expect(() => utils.removeTraceId(undefined as any)).not.toThrow(); + }); + }); + + describe("isTimestampASC", () => { + it("should return true when timestamp is ordered ASC", () => { + const orderby = [ + { expr: { column: "_timestamp" }, type: "ASC" }, + ]; + expect(utils.isTimestampASC(orderby)).toBe(true); + }); + + it("should return false when timestamp is ordered DESC", () => { + const orderby = [ + { expr: { column: "_timestamp" }, type: "DESC" }, + ]; + expect(utils.isTimestampASC(orderby)).toBe(false); + }); + + it("should return false when no orderby", () => { + expect(utils.isTimestampASC(null)).toBe(false); + expect(utils.isTimestampASC(undefined)).toBe(false); + }); + + it("should return false when ordering different column", () => { + const orderby = [ + { expr: { column: "other_field" }, type: "ASC" }, + ]; + expect(utils.isTimestampASC(orderby)).toBe(false); + }); + + it("should check timestamp column from store config", () => { + mockStore.state.zoConfig.timestamp_column = "custom_ts"; + const orderby = [ + { expr: { column: "custom_ts" }, type: "ASC" }, + ]; + // Re-get utils to pick up new config + const newUtils = logsUtils(); + expect(newUtils.isTimestampASC(orderby)).toBe(true); + }); + }); + + describe("checkTimestampAlias", () => { + beforeEach(() => { + mockStore.state.zoConfig.timestamp_column = "_timestamp"; + }); + + it("should return true for query without timestamp alias", () => { + mockParserInstance.astify.mockReturnValue({ + columns: [{ as: "my_field" }, { as: "other_field" }], + }); + expect(utils.checkTimestampAlias("SELECT field1 as my_field FROM logs")).toBe(true); + }); + + it("should return false when column is aliased as timestamp", () => { + mockParserInstance.astify.mockReturnValue({ + columns: [{ as: "_timestamp" }], + }); + expect(utils.checkTimestampAlias("SELECT field1 as _timestamp FROM logs")).toBe(false); + }); + + it("should return false for AS '_timestamp' pattern in query", () => { + mockParserInstance.astify.mockReturnValue({ columns: [] }); + expect(utils.checkTimestampAlias("SELECT field1 AS '_timestamp' FROM logs")).toBe(false); + }); + + it("should return false for AS \"_timestamp\" pattern in query", () => { + mockParserInstance.astify.mockReturnValue({ columns: [] }); + expect(utils.checkTimestampAlias('SELECT field1 AS "_timestamp" FROM logs')).toBe(false); + }); + + it("should return false for AS _timestamp (unquoted) pattern in query", () => { + mockParserInstance.astify.mockReturnValue({ columns: [] }); + expect(utils.checkTimestampAlias("SELECT field1 AS _timestamp FROM logs")).toBe(false); + }); + + it("should handle columns not being an array", () => { + mockParserInstance.astify.mockReturnValue({ columns: null }); + expect(utils.checkTimestampAlias("SELECT * FROM logs")).toBe(true); + }); + }); + + describe("isNonAggregatedSQLMode", () => { + it("should return true when not in SQL mode", () => { + const searchObjParam = { meta: { sqlMode: false }, data: { queryResults: { is_histogram_eligible: true } } }; + const parsedSQL = {}; + expect(utils.isNonAggregatedSQLMode(searchObjParam, parsedSQL)).toBe(true); + }); + + it("should return false when SQL mode with LIMIT query", () => { + const searchObjParam = { meta: { sqlMode: true }, data: { queryResults: { is_histogram_eligible: true } } }; + const parsedSQL = { limit: { value: [{ value: 10 }] } }; + expect(utils.isNonAggregatedSQLMode(searchObjParam, parsedSQL)).toBe(false); + }); + + it("should return false when SQL mode with DISTINCT query", () => { + const searchObjParam = { meta: { sqlMode: true }, data: { queryResults: { is_histogram_eligible: true } } }; + const parsedSQL = { distinct: { type: "DISTINCT" } }; + expect(utils.isNonAggregatedSQLMode(searchObjParam, parsedSQL)).toBe(false); + }); + + it("should return false when SQL mode with WITH query", () => { + const searchObjParam = { meta: { sqlMode: true }, data: { queryResults: { is_histogram_eligible: true } } }; + const parsedSQL = { with: [{ name: "cte" }] }; + expect(utils.isNonAggregatedSQLMode(searchObjParam, parsedSQL)).toBe(false); + }); + + it("should return false when not histogram eligible", () => { + const searchObjParam = { meta: { sqlMode: true }, data: { queryResults: { is_histogram_eligible: false } } }; + const parsedSQL = {}; + expect(utils.isNonAggregatedSQLMode(searchObjParam, parsedSQL)).toBe(false); + }); + + it("should return true for simple SQL mode query", () => { + const searchObjParam = { meta: { sqlMode: true }, data: { queryResults: { is_histogram_eligible: true } } }; + const parsedSQL = {}; + expect(utils.isNonAggregatedSQLMode(searchObjParam, parsedSQL)).toBe(true); + }); + }); + + describe("showCancelSearchNotification", () => { + it("should call notify with correct parameters", () => { + utils.showCancelSearchNotification(); + expect(mockNotify).toHaveBeenCalledWith({ + message: "Running query cancelled successfully", + color: "positive", + position: "bottom", + timeout: 4000, + }); + }); + }); + + describe("generateURLQuery", () => { + beforeEach(() => { + mockSearchObj.data.datetime = { + type: "relative", + startTime: new Date().getTime(), + endTime: new Date().getTime(), + relativeTimePeriod: "15m", + }; + mockSearchObj.data.stream = { + streamType: "logs", + selectedStream: ["default"], + selectedFields: [], + }; + mockSearchObj.data.query = "SELECT * FROM logs"; + mockSearchObj.meta.sqlMode = true; + mockSearchObj.meta.quickMode = true; + mockSearchObj.meta.showHistogram = true; + mockSearchObj.meta.refreshInterval = 0; + mockSearchObj.meta.logsVisualizeToggle = "logs"; + }); + + it("should generate query params with stream info", () => { + const query = utils.generateURLQuery(); + expect(query.stream_type).toBe("logs"); + expect(query.stream).toBe("default"); + }); + + it("should include relative time period for relative datetime", () => { + const query = utils.generateURLQuery(); + expect(query.period).toBe("15m"); + }); + + it("should include from/to for absolute datetime", () => { + mockSearchObj.data.datetime.type = "absolute"; + mockSearchObj.data.datetime.startTime = 1000000; + mockSearchObj.data.datetime.endTime = 2000000; + + const query = utils.generateURLQuery(); + expect(query.from).toBe(1000000); + expect(query.to).toBe(2000000); + }); + + it("should include from/to for share link even with relative time", () => { + mockSearchObj.data.datetime.startTime = 1000000; + mockSearchObj.data.datetime.endTime = 2000000; + + const query = utils.generateURLQuery(true); + expect(query.from).toBe(1000000); + expect(query.to).toBe(2000000); + }); + + it("should include sql_mode and query when query exists", () => { + const query = utils.generateURLQuery(); + expect(query.sql_mode).toBe(true); + expect(query.query).toBeDefined(); + }); + + it("should include quick_mode setting", () => { + const query = utils.generateURLQuery(); + expect(query.quick_mode).toBe(true); + }); + + it("should include show_histogram setting", () => { + const query = utils.generateURLQuery(); + expect(query.show_histogram).toBe(true); + }); + + it("should include organization identifier", () => { + const query = utils.generateURLQuery(); + expect(query.org_identifier).toBe("test-org"); + }); + + it("should include logsVisualizeToggle when set", () => { + mockSearchObj.meta.logsVisualizeToggle = "visualize"; + const query = utils.generateURLQuery(); + expect(query.logs_visualize_toggle).toBe("visualize"); + }); + + it("should handle stream as object with value property", () => { + mockSearchObj.data.stream.selectedStream = { value: "my-stream" } as any; + const query = utils.generateURLQuery(); + expect(query.stream).toBe("my-stream"); + }); + }); + + describe("shouldAddFunctionToSearch", () => { + it("should return true when transform editor is shown with content", () => { + mockSearchObj.data.tempFunctionContent = "function content"; + mockSearchObj.meta.showTransformEditor = true; + expect(utils.shouldAddFunctionToSearch()).toBe(true); + }); + + it("should return false when no function content", () => { + mockSearchObj.data.tempFunctionContent = ""; + mockSearchObj.meta.showTransformEditor = true; + expect(utils.shouldAddFunctionToSearch()).toBe(false); + }); + + it("should return false when transform editor is not shown", () => { + mockSearchObj.data.tempFunctionContent = "function content"; + mockSearchObj.meta.showTransformEditor = false; + expect(utils.shouldAddFunctionToSearch()).toBe(false); + }); + }); + + describe("addTransformToQuery", () => { + it("should add query_fn when function should be added", () => { + mockSearchObj.data.tempFunctionContent = "function content"; + mockSearchObj.meta.showTransformEditor = true; + + const queryReq = { query: {} }; + utils.addTransformToQuery(queryReq); + + expect(queryReq.query).toHaveProperty("query_fn"); + }); + + it("should add action_id when transform type is action", () => { + mockSearchObj.data.transformType = "action"; + mockSearchObj.data.selectedTransform = { id: "action-123" }; + + const queryReq = { query: {} }; + utils.addTransformToQuery(queryReq); + + expect(queryReq.query).toHaveProperty("action_id", "action-123"); + }); + + it("should not modify query when no transform to add", () => { + mockSearchObj.data.tempFunctionContent = ""; + mockSearchObj.data.transformType = ""; + mockSearchObj.data.selectedTransform = null; + + const queryReq = { query: {} }; + utils.addTransformToQuery(queryReq); + + expect(queryReq.query).not.toHaveProperty("query_fn"); + expect(queryReq.query).not.toHaveProperty("action_id"); + }); + }); +}); diff --git a/web/src/composables/useLogs/logsUtils.ts b/web/src/composables/useLogs/logsUtils.ts index 480ed5b63b..4aaa610c9e 100644 --- a/web/src/composables/useLogs/logsUtils.ts +++ b/web/src/composables/useLogs/logsUtils.ts @@ -23,6 +23,8 @@ import { b64EncodeUnicode, useLocalLogFilterField, } from "@/utils/zincutils"; import { encodeVisualizationConfig, getVisualizationConfig, + encodeBuildConfig, + getBuildConfig, } from "@/composables/useLogs/logsVisualization"; import { searchState } from "@/composables/useLogs/searchState"; @@ -528,6 +530,7 @@ export const logsUtils = () => { const generateURLQuery = ( isShareLink: boolean = false, dashboardPanelData: any = null, + buildPanelData: any = null, ) => { const date = searchObj.data.datetime; @@ -632,11 +635,33 @@ export const logsUtils = () => { } } + // Preserve build data in URL + // - If in build mode and build panel data is provided, encode the buildPanelData + if ( + searchObj.meta.logsVisualizeToggle === "build" && + buildPanelData + ) { + const buildData = getBuildConfig(buildPanelData); + if (buildData) { + const encoded = encodeBuildConfig(buildData); + if (encoded) { + query["build_data"] = encoded; + } + } + } else { + // else preserve existing build data from the current URL + const existingEncodedBuildConfig = router.currentRoute.value?.query + ?.build_data as string | undefined; + if (existingEncodedBuildConfig) { + query["build_data"] = existingEncodedBuildConfig; + } + } + return query; }; - const updateUrlQueryParams = (dashboardPanelData: any = null) => { - const query = generateURLQuery(false, dashboardPanelData); + const updateUrlQueryParams = (dashboardPanelData: any = null, buildPanelData: any = null) => { + const query = generateURLQuery(false, dashboardPanelData, buildPanelData); if ( (Object.hasOwn(query, "type") && query.type == "search_history_re_apply") || query.type == "search_scheduler" diff --git a/web/src/composables/useLogs/logsVisualization.spec.ts b/web/src/composables/useLogs/logsVisualization.spec.ts index f4ca11d449..8daa1ea674 100644 --- a/web/src/composables/useLogs/logsVisualization.spec.ts +++ b/web/src/composables/useLogs/logsVisualization.spec.ts @@ -14,10 +14,13 @@ // along with this program. If not, see . import { describe, expect, it } from "vitest"; -import { - getVisualizationConfig, - encodeVisualizationConfig, - decodeVisualizationConfig +import { + getVisualizationConfig, + encodeVisualizationConfig, + decodeVisualizationConfig, + getBuildConfig, + encodeBuildConfig, + decodeBuildConfig, } from "./logsVisualization"; describe('logsVisualization Functions', () => { @@ -123,9 +126,133 @@ describe('logsVisualization Functions', () => { it("should handle null input", () => { const result = decodeVisualizationConfig(null as any); - + // Returns empty object as fallback for null input expect(result).toEqual({}); }); }); +}); + +describe("Build Configuration Functions", () => { + describe("getBuildConfig", () => { + it("should extract build config from dashboard panel data", () => { + const dashboardPanelData = { + data: { + config: { showLegend: true }, + type: "bar", + queries: [ + { + fields: { + stream: "test_stream", + stream_type: "logs", + x: [{ column: "timestamp" }], + y: [{ column: "count" }], + breakdown: [], + filter: { filterType: "group", logicalOperator: "AND", conditions: [] }, + }, + customQuery: false, + query: "", + }, + ], + }, + }; + + const config = getBuildConfig(dashboardPanelData); + + expect(config).toEqual({ + config: { showLegend: true }, + type: "bar", + fields: { + stream: "test_stream", + stream_type: "logs", + x: [{ column: "timestamp" }], + y: [{ column: "count" }], + breakdown: [], + filter: { filterType: "group", logicalOperator: "AND", conditions: [] }, + }, + joins: [], + customQuery: false, + query: "", + }); + }); + + it("should handle empty dashboard panel data", () => { + const dashboardPanelData = {}; + const config = getBuildConfig(dashboardPanelData); + + expect(config).toBeNull(); + }); + + it("should handle null dashboard panel data", () => { + const config = getBuildConfig(null); + + expect(config).toBeNull(); + }); + + it("should handle missing queries", () => { + const dashboardPanelData = { + data: { + config: {}, + type: "line", + queries: [], + }, + }; + + const config = getBuildConfig(dashboardPanelData); + + expect(config).toBeNull(); + }); + }); + + describe("encodeBuildConfig", () => { + it("should encode build configuration object to base64", () => { + const config = { + type: "metric", + fields: { stream: "logs" }, + }; + + const encoded = encodeBuildConfig(config); + + expect(encoded).toBeTruthy(); + expect(typeof encoded).toBe("string"); + }); + + it("should handle empty configuration", () => { + const encoded = encodeBuildConfig({}); + + expect(encoded).toBeTruthy(); + expect(typeof encoded).toBe("string"); + }); + }); + + describe("decodeBuildConfig", () => { + it("should decode base64 string to build configuration object", () => { + const originalConfig = { + type: "bar", + fields: { + stream: "test", + x: [], + y: [{ column: "count" }], + }, + }; + + const encoded = encodeBuildConfig(originalConfig); + const decoded = decodeBuildConfig(encoded!); + + expect(decoded).toEqual(originalConfig); + }); + + it("should handle invalid base64 string", () => { + const result = decodeBuildConfig("invalid-base64"); + + // Should handle the error gracefully, returns empty object as fallback + expect(result).toEqual({}); + }); + + it("should handle empty string", () => { + const result = decodeBuildConfig(""); + + expect(result).toBeNull(); + }); + }); }); \ No newline at end of file diff --git a/web/src/composables/useLogs/logsVisualization.ts b/web/src/composables/useLogs/logsVisualization.ts index 33f86440b8..63f13dca49 100644 --- a/web/src/composables/useLogs/logsVisualization.ts +++ b/web/src/composables/useLogs/logsVisualization.ts @@ -44,3 +44,62 @@ export const decodeVisualizationConfig = (encodedConfig: string) => { return null; } }; + +// ============================================================================ +// Build Page Configuration Helpers +// ============================================================================ + +/** + * Extract build configuration from dashboard panel data for URL sharing + * Includes builder fields, chart type, and config + */ +export const getBuildConfig = (dashboardPanelData: any) => { + if (!dashboardPanelData?.data) { + return null; + } + + const currentQuery = dashboardPanelData.data.queries?.[0]; + if (!currentQuery) { + return null; + } + + return { + config: dashboardPanelData.data.config || {}, + type: dashboardPanelData.data.type || "line", + fields: { + stream: currentQuery.fields?.stream || "", + stream_type: currentQuery.fields?.stream_type || "logs", + x: currentQuery.fields?.x || [], + y: currentQuery.fields?.y || [], + breakdown: currentQuery.fields?.breakdown || [], + filter: currentQuery.fields?.filter || { filterType: "group", logicalOperator: "AND", conditions: [] }, + }, + joins: currentQuery.joins || [], + customQuery: currentQuery.customQuery || false, + query: currentQuery.query || "", + }; +}; + +/** + * Encode build configuration for URL + */ +export const encodeBuildConfig = (config: any) => { + try { + return b64EncodeUnicode(JSON.stringify(config)); + } catch (error) { + console.error("Failed to encode build config:", error); + return null; + } +}; + +/** + * Decode build configuration from URL + */ +export const decodeBuildConfig = (encodedConfig: string) => { + try { + return JSON.parse(b64DecodeUnicode(encodedConfig) ?? "{}"); + } catch (error) { + console.error("Failed to decode build config:", error); + return null; + } +}; diff --git a/web/src/locales/languages/de.json b/web/src/locales/languages/de.json index 3657ff2821..183849d523 100644 --- a/web/src/locales/languages/de.json +++ b/web/src/locales/languages/de.json @@ -112,7 +112,10 @@ "id": "ID", "getStarted": "Fangen Sie an", "reset": "Zurücksetzen", - "retry": "Erneut versuchen" + "retry": "Erneut versuchen", + "copy": "Kopieren", + "copySuccess": "In die Zwischenablage kopiert", + "copyError": "Fehler beim Kopieren in die Zwischenablage" }, "search": { "selectIndex": "Wählen Sie Zuerst streamen", @@ -312,7 +315,11 @@ "showHideColumns": "Spalten ein-/ausblenden", "autoRefresh": "Automatische Aktualisierung", "createSavedView": "Gespeicherte Ansicht erstellen", - "listSavedViews": "Gespeicherte Ansichten auflisten" + "listSavedViews": "Gespeicherte Ansichten auflisten", + "buildQuery": "Bauen", + "enableSqlModeForBuild": "Bitte aktivieren Sie den SQL-Modus und wählen Sie einen einzelnen Stream aus, um den Query Builder zu verwenden", + "applyToSearch": "Für die Suche bewerben", + "complexQueryDetected": "Eine komplexe Abfrage wurde erkannt. Im benutzerdefinierten Modus." }, "menu": { "home": "Startseite", @@ -1487,7 +1494,10 @@ "SQL": "SQL", "builder": "Baumeister", "custom": "Benutzerdefiniert", - "sqlBuilder": "SQL-Generator" + "sqlBuilder": "SQL-Generator", + "generatedSql": "Generiertes SQL", + "enterCustomSql": "Geben Sie Ihre benutzerdefinierte SQL-Abfrage ein...", + "customSql": "Benutzerdefiniertes SQL" }, "home": { "streams": "Streams", diff --git a/web/src/locales/languages/en.json b/web/src/locales/languages/en.json index 47850a861f..18937df902 100644 --- a/web/src/locales/languages/en.json +++ b/web/src/locales/languages/en.json @@ -47,6 +47,9 @@ "manageTheme": "Manage Theme", "minRefreshIntervalMessage": "The minimum refresh interval is {interval} seconds. Please adjust the minimum refresh interval accordingly.", "searchAround": "Search Around", + "copy": "Copy", + "copySuccess": "Copied to clipboard", + "copyError": "Failed to copy to clipboard", "copyToClipboard": "Copy to clipboard", "includeSearchTerm": "Include Search Term", "addFieldToTable": "Add field to the table", @@ -241,6 +244,10 @@ "enableSqlModeOrSelectSingleStream": "Please enable SQL mode to visualize", "visualizeDisabledForMultiStream": "Visualization is disabled for multi stream", "sqlModeDisabledForVisualization": "SQL mode cannot be disabled while on visualization with multiple streams", + "buildQuery": "Build", + "enableSqlModeForBuild": "Please enable SQL mode and select a single stream to use query builder", + "applyToSearch": "Apply to Search", + "complexQueryDetected": "Complex query detected. Using custom mode.", "transformType": "Transform Type", "saveActionDisabled": "Saving Action is currently unavailable", "moreActions": "More Actions", @@ -1670,6 +1677,9 @@ "fields": "Fields", "layout": "Layout", "sql": "Query", + "customSql": "Custom SQL", + "generatedSql": "Generated SQL", + "enterCustomSql": "Enter your custom SQL query...", "back": "<< Back", "sqlBuilder": "SQL Builder", "promQL": "PromQL", diff --git a/web/src/locales/languages/es.json b/web/src/locales/languages/es.json index afe05385a0..30cbb4b7f3 100644 --- a/web/src/locales/languages/es.json +++ b/web/src/locales/languages/es.json @@ -112,7 +112,10 @@ "id": "CARNÉ DE IDENTIDAD", "getStarted": "Comenzar", "reset": "Reiniciar", - "retry": "Reintentar" + "retry": "Reintentar", + "copy": "Copiar", + "copySuccess": "Copiado al portapapeles", + "copyError": "No se pudo copiar al portapapeles" }, "search": { "selectIndex": "Selecciona el índice primero", @@ -312,7 +315,11 @@ "showHideColumns": "Mostrar/ocultar columnas", "autoRefresh": "Actualización automática", "createSavedView": "Crear vista guardada", - "listSavedViews": "Listar vistas guardadas" + "listSavedViews": "Listar vistas guardadas", + "buildQuery": "Construir", + "enableSqlModeForBuild": "Habilite el modo SQL y seleccione una sola secuencia para usar el generador de consultas", + "applyToSearch": "Aplicar a la búsqueda", + "complexQueryDetected": "Se detectó una consulta compleja. Uso del modo personalizado." }, "menu": { "home": "Inicio", @@ -1487,7 +1494,10 @@ "SQL": "SQL", "builder": "Constructor", "custom": "Personalizado", - "sqlBuilder": "Constructor SQL" + "sqlBuilder": "Constructor SQL", + "generatedSql": "SQL generado", + "enterCustomSql": "Introduzca su consulta SQL personalizada...", + "customSql": "SQL personalizado" }, "home": { "streams": "Transmisiones", diff --git a/web/src/locales/languages/fr.json b/web/src/locales/languages/fr.json index 887268d1cb..868494fb8a 100644 --- a/web/src/locales/languages/fr.json +++ b/web/src/locales/languages/fr.json @@ -112,7 +112,10 @@ "id": "IDENTIFIANT", "getStarted": "Commencez", "reset": "Réinitialiser", - "retry": "Réessayez" + "retry": "Réessayez", + "copy": "Copier", + "copySuccess": "Copié dans le presse-papiers", + "copyError": "Impossible de copier dans le presse-papiers" }, "search": { "selectIndex": "Sélectionnez Stream First", @@ -312,7 +315,11 @@ "showHideColumns": "Afficher/masquer les colonnes", "autoRefresh": "Actualisation automatique", "createSavedView": "Créer une vue enregistrée", - "listSavedViews": "Répertorier les vues enregistrées" + "listSavedViews": "Répertorier les vues enregistrées", + "buildQuery": "Construisez", + "enableSqlModeForBuild": "Activez le mode SQL et sélectionnez un seul flux pour utiliser le générateur de requêtes", + "applyToSearch": "Appliquer à la recherche", + "complexQueryDetected": "Requête complexe détectée. Utilisation du mode personnalisé." }, "menu": { "home": "Accueil", @@ -1487,7 +1494,10 @@ "SQL": "SQL", "builder": "Constructeur", "custom": "Personnalisé", - "sqlBuilder": "Générateur SQL" + "sqlBuilder": "Générateur SQL", + "generatedSql": "SQL généré", + "enterCustomSql": "Entrez votre requête SQL personnalisée...", + "customSql": "SQL personnalisé" }, "home": { "streams": "Ruisseaux", diff --git a/web/src/locales/languages/it.json b/web/src/locales/languages/it.json index 724ee3a27f..c8e52c930e 100644 --- a/web/src/locales/languages/it.json +++ b/web/src/locales/languages/it.json @@ -112,7 +112,10 @@ "id": "ID", "getStarted": "Inizia", "reset": "Reimposta", - "retry": "Riprova" + "retry": "Riprova", + "copy": "Copia", + "copySuccess": "Copiato negli appunti", + "copyError": "Copia negli appunti non riuscita" }, "search": { "selectIndex": "Seleziona Stream First", @@ -312,7 +315,11 @@ "showHideColumns": "Mostra/nascondi colonne", "autoRefresh": "Aggiornamento automatico", "createSavedView": "Crea visualizzazione salvata", - "listSavedViews": "Elenca visualizzazioni salvate" + "listSavedViews": "Elenca visualizzazioni salvate", + "buildQuery": "Costruisci", + "enableSqlModeForBuild": "Abilita la modalità SQL e seleziona un singolo stream per utilizzare Query Builder", + "applyToSearch": "Applica alla ricerca", + "complexQueryDetected": "È stata rilevata una query complessa. Utilizzo della modalità personalizzata." }, "menu": { "home": "Home", @@ -1487,7 +1494,10 @@ "SQL": "SQL", "builder": "Costruttore", "custom": "Personalizzato", - "sqlBuilder": "SQL Builder" + "sqlBuilder": "SQL Builder", + "generatedSql": "SQL generato", + "enterCustomSql": "Inserisci la tua query SQL personalizzata...", + "customSql": "SQL personalizzato" }, "home": { "streams": "Stream", diff --git a/web/src/locales/languages/ja.json b/web/src/locales/languages/ja.json index 8d48720697..031b06825e 100644 --- a/web/src/locales/languages/ja.json +++ b/web/src/locales/languages/ja.json @@ -111,6 +111,9 @@ "chunkLoadErrorMsg": "アプリケーションの新しいバージョンが利用可能です。ページを更新して続行してください。", "id": "ID", "getStarted": "始めよう", + "copy": "[コピー]", + "copySuccess": "クリップボードにコピー", + "copyError": "クリップボードへのコピーに失敗しました", "reset": "リセット", "retry": "リトライ" }, @@ -312,7 +315,11 @@ "showHideColumns": "列を表示/非表示", "autoRefresh": "自動更新", "createSavedView": "保存済みビューを作成", - "listSavedViews": "保存済みビューを一覧表示" + "listSavedViews": "保存済みビューを一覧表示", + "buildQuery": "ビルド", + "enableSqlModeForBuild": "SQL モードを有効にして、クエリビルダーを使用するストリームを 1 つ選択してください", + "applyToSearch": "検索に適用", + "complexQueryDetected": "複雑なクエリが検出されました。カスタムモードを使用する。" }, "menu": { "home": "ホーム", @@ -1504,7 +1511,10 @@ "SQL": "SQL", "builder": "ビルダー", "custom": "カスタム", - "sqlBuilder": "SQL ビルダー" + "sqlBuilder": "SQL ビルダー", + "generatedSql": "生成された SQL", + "enterCustomSql": "カスタム SQL クエリを入力してください...", + "customSql": "カスタム SQL" }, "home": { "streams": "ストリーム", diff --git a/web/src/locales/languages/ko.json b/web/src/locales/languages/ko.json index ab30e65a5a..77aeb730f6 100644 --- a/web/src/locales/languages/ko.json +++ b/web/src/locales/languages/ko.json @@ -111,6 +111,9 @@ "chunkLoadErrorMsg": "새 버전의 응용 프로그램을 사용할 수 있습니다.계속하려면 페이지를 새로고침하세요.", "id": "신분증", "getStarted": "시작하기", + "copy": "복사", + "copySuccess": "클립보드에 복사됨", + "copyError": "클립보드에 복사하지 못했습니다.", "reset": "초기화", "retry": "재시도" }, @@ -312,7 +315,11 @@ "showHideColumns": "열 표시/숨기기", "autoRefresh": "자동 새로고침", "createSavedView": "저장된 뷰 생성", - "listSavedViews": "저장된 뷰 목록" + "listSavedViews": "저장된 뷰 목록", + "buildQuery": "짓다", + "enableSqlModeForBuild": "SQL 모드를 활성화하고 쿼리 작성기를 사용할 단일 스트림을 선택하십시오.", + "applyToSearch": "검색에 적용", + "complexQueryDetected": "복잡한 쿼리가 감지되었습니다.커스텀 모드 사용." }, "menu": { "home": "홈", @@ -1487,7 +1494,10 @@ "SQL": "SQL", "builder": "건축업자", "custom": "사용자 지정", - "sqlBuilder": "SQL 빌더" + "sqlBuilder": "SQL 빌더", + "generatedSql": "생성된 SQL", + "enterCustomSql": "사용자 지정 SQL 쿼리를 입력하세요...", + "customSql": "사용자 지정 SQL" }, "home": { "streams": "스트림", diff --git a/web/src/locales/languages/nl.json b/web/src/locales/languages/nl.json index 7557daf5aa..ecc870b0be 100644 --- a/web/src/locales/languages/nl.json +++ b/web/src/locales/languages/nl.json @@ -112,7 +112,10 @@ "id": "ID", "getStarted": "Ga aan de slag", "reset": "Resetten", - "retry": "Probeer het opnieuw" + "retry": "Probeer het opnieuw", + "copy": "Kopiëren", + "copySuccess": "Gekopieerd naar klembord", + "copyError": "Kopiëren naar klembord is mislukt" }, "search": { "selectIndex": "Selecteer eerst Stream", @@ -312,7 +315,11 @@ "showHideColumns": "Kolommen tonen/verbergen", "autoRefresh": "Automatisch verversen", "createSavedView": "Opgeslagen weergave maken", - "listSavedViews": "Opgeslagen weergaven weergeven" + "listSavedViews": "Opgeslagen weergaven weergeven", + "buildQuery": "Bouwen", + "enableSqlModeForBuild": "Schakel de SQL-modus in en selecteer een enkele stream om de querybuilder te gebruiken", + "applyToSearch": "Toepassen om te zoeken", + "complexQueryDetected": "Complexe zoekopdracht gedetecteerd. Met behulp van de aangepaste modus." }, "menu": { "home": "Home", @@ -1487,7 +1494,10 @@ "SQL": "SQL", "builder": "Bouwer", "custom": "Op maat", - "sqlBuilder": "SQL Builder" + "sqlBuilder": "SQL Builder", + "generatedSql": "Gegenereerde SQL", + "enterCustomSql": "Voer uw aangepaste SQL-query in...", + "customSql": "SQL op maat" }, "home": { "streams": "Streams", diff --git a/web/src/locales/languages/pt.json b/web/src/locales/languages/pt.json index 6c60941f17..fe2a4d2886 100644 --- a/web/src/locales/languages/pt.json +++ b/web/src/locales/languages/pt.json @@ -112,7 +112,10 @@ "id": "CARTEIRA DE IDENTIDADE", "getStarted": "Comece", "reset": "Redefinir", - "retry": "Tente novamente" + "retry": "Tente novamente", + "copy": "Copiar", + "copySuccess": "Copiado para a prancheta", + "copyError": "Falha ao copiar para a área de transferência" }, "search": { "selectIndex": "Selecione Stream First", @@ -312,7 +315,11 @@ "showHideColumns": "Mostrar/ocultar colunas", "autoRefresh": "Atualização automática", "createSavedView": "Criar visualização salva", - "listSavedViews": "Listar visualizações salvas" + "listSavedViews": "Listar visualizações salvas", + "buildQuery": "Construir", + "enableSqlModeForBuild": "Ative o modo SQL e selecione um único fluxo para usar o construtor de consultas", + "applyToSearch": "Aplicar para pesquisar", + "complexQueryDetected": "Consulta complexa detectada. Usando o modo personalizado." }, "menu": { "home": "Início", @@ -1487,7 +1494,10 @@ "SQL": "SQL", "builder": "Construtor", "custom": "Personalizado", - "sqlBuilder": "Construtor de SQL" + "sqlBuilder": "Construtor de SQL", + "generatedSql": "SQL gerado", + "enterCustomSql": "Insira sua consulta SQL personalizada...", + "customSql": "SQL personalizado" }, "home": { "streams": "Transmissões", diff --git a/web/src/locales/languages/tr.json b/web/src/locales/languages/tr.json index 94501e9f4f..c1f5357bfc 100644 --- a/web/src/locales/languages/tr.json +++ b/web/src/locales/languages/tr.json @@ -112,7 +112,10 @@ "id": "KİMLİK", "getStarted": "Başlayın", "reset": "Sıfırla", - "retry": "Tekrar dene" + "retry": "Tekrar dene", + "copy": "Kopyalama", + "copySuccess": "Panoya kopyalandı", + "copyError": "Panoya kopyalanamadı" }, "search": { "selectIndex": "Önce Yayınla Seçin", @@ -312,7 +315,11 @@ "showHideColumns": "Sütunları Göster/Gizle", "autoRefresh": "Otomatik Yenileme", "createSavedView": "Kayıtlı Görünüm Oluştur", - "listSavedViews": "Kayıtlı Görünümleri Listele" + "listSavedViews": "Kayıtlı Görünümleri Listele", + "buildQuery": "İnşa", + "enableSqlModeForBuild": "Lütfen SQL modunu etkinleştirin ve sorgu oluşturucuyu kullanmak için tek bir akış seçin", + "applyToSearch": "Aramaya Uygula", + "complexQueryDetected": "Karmaşık sorgu algılandı. Özel modu kullanma." }, "menu": { "home": "Ana Sayfa", @@ -1487,7 +1494,10 @@ "SQL": "SQL", "builder": "inşaatçı", "custom": "Özel", - "sqlBuilder": "SQL Oluşturucu" + "sqlBuilder": "SQL Oluşturucu", + "generatedSql": "Oluşturulan SQL", + "enterCustomSql": "Özel SQL sorgunuzu girin...", + "customSql": "Özel SQL" }, "home": { "streams": "Akışlar", diff --git a/web/src/locales/languages/zh.json b/web/src/locales/languages/zh.json index 40b539cb5d..26d8f44ffd 100644 --- a/web/src/locales/languages/zh.json +++ b/web/src/locales/languages/zh.json @@ -111,6 +111,9 @@ "chunkLoadErrorMsg": "该应用程序的新版本可用。请刷新页面继续。", "id": "身份证", "getStarted": "开始吧", + "copy": "复制", + "copySuccess": "已复制到剪贴板", + "copyError": "无法复制到剪贴板", "reset": "重置", "retry": "重试" }, @@ -312,7 +315,11 @@ "showHideColumns": "显示/隐藏列", "autoRefresh": "自动刷新", "createSavedView": "创建保存的视图", - "listSavedViews": "列出保存的视图" + "listSavedViews": "列出保存的视图", + "buildQuery": "构建", + "enableSqlModeForBuild": "请启用 SQL 模式并选择单个流来使用查询生成器", + "applyToSearch": "申请搜索", + "complexQueryDetected": "检测到复杂查询。使用自定义模式。" }, "menu": { "home": "主页", @@ -1487,7 +1494,10 @@ "SQL": "SQL", "builder": "生成器", "custom": "自定义", - "sqlBuilder": "SQL 生成器" + "sqlBuilder": "SQL 生成器", + "generatedSql": "生成的 SQL", + "enterCustomSql": "输入您的自定义 SQL 查询...", + "customSql": "自定义 SQL" }, "home": { "streams": "数据流", diff --git a/web/src/plugins/logs/BuildQueryPage.spec.ts b/web/src/plugins/logs/BuildQueryPage.spec.ts new file mode 100644 index 0000000000..8e309c3550 --- /dev/null +++ b/web/src/plugins/logs/BuildQueryPage.spec.ts @@ -0,0 +1,768 @@ +// Copyright 2023 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { reactive, nextTick } from "vue"; +import { createI18n } from "vue-i18n"; +import { Quasar, Dialog, Notify } from "quasar"; +import BuildQueryPage from "./BuildQueryPage.vue"; + +// Mock vuex store +const mockStore = { + state: { + timezone: "UTC", + theme: "light", + selectedOrganization: { + identifier: "test-org", + }, + organizationData: { + isDataIngested: true, + }, + zoConfig: { + timestamp_column: "_timestamp", + default_functions: [], + }, + savedViewDialog: { + show: false, + }, + }, + commit: vi.fn(), + dispatch: vi.fn().mockResolvedValue({}), + getters: {}, +}; + +vi.mock("vuex", () => ({ + useStore: () => mockStore, +})); + +// Mock vue-router +const mockRouter = { + push: vi.fn(), + currentRoute: { + value: { + name: "logs", + query: {}, // Empty query - no build_data in URL + }, + }, +}; + +vi.mock("vue-router", () => ({ + useRouter: () => mockRouter, +})); + +// Mock logsVisualization +vi.mock("@/composables/useLogs/logsVisualization", () => ({ + decodeBuildConfig: vi.fn().mockReturnValue(null), +})); + +// Mock SQL query parser +vi.mock("@/utils/query/sqlQueryParser", () => ({ + parseSQL: vi.fn().mockResolvedValue({ + stream: "test_stream", + streamType: "logs", + xFields: [], + yFields: [], + breakdownFields: [], + filters: [], + groupBy: [], + customQuery: false, + rawQuery: "", + }), + shouldUseCustomMode: vi.fn().mockReturnValue(false), + parsedQueryToPanelFields: vi.fn().mockReturnValue({ + stream: "test_stream", + stream_type: "logs", + x: [], + y: [], + breakdown: [], + filter: [], + }), +})); + +// Mock useDashboardPanelData +const mockDashboardPanelData = reactive({ + data: { + description: "", + type: "line", + config: { + table_dynamic_columns: false, + }, + queries: [ + { + query: "", + customQuery: false, + fields: { + stream: "", + stream_type: "logs", + x: [], + y: [], + z: [], + breakdown: [], + filter: [], + }, + }, + ], + }, + layout: { + splitter: 20, + showFieldList: true, + showQueryBar: true, + querySplitter: 50, + currentQueryIndex: 0, + isConfigPanelOpen: false, + }, + meta: { + dateTime: { + start_time: new Date("2024-01-01"), + end_time: new Date("2024-01-02"), + }, + stream: { + customQueryFields: [], + vrlFunctionFieldList: [], + }, + streamFields: { + groupedFields: [], + }, + }, +}); + +const mockResetDashboardPanelData = vi.fn(); +const mockMakeAutoSQLQuery = vi.fn(); +const mockUpdateGroupedFields = vi.fn().mockResolvedValue(undefined); + +vi.mock("@/composables/useDashboardPanel", () => ({ + default: () => ({ + dashboardPanelData: mockDashboardPanelData, + resetAggregationFunction: vi.fn(), + resetDashboardPanelData: mockResetDashboardPanelData, + makeAutoSQLQuery: mockMakeAutoSQLQuery, + updateGroupedFields: mockUpdateGroupedFields, + }), +})); + +// Mock PanelEditor component +vi.mock("@/components/dashboards/PanelEditor/PanelEditor.vue", () => ({ + default: { + name: "PanelEditor", + template: '
', + props: ["pageType", "editMode", "selectedDateTime", "showAddToDashboardButton"], + emits: ["addToDashboard", "chartApiError", "queryGenerated", "customQueryModeChanged"], + methods: { + runQuery: vi.fn(), + }, + }, +})); + +// Mock AddToDashboard component +vi.mock("@/plugins/metrics/AddToDashboard.vue", () => ({ + default: { + name: "AddToDashboard", + template: '
AddToDashboard
', + props: ["dashboardPanelData"], + emits: ["save"], + }, +})); + +// Mock QueryTypeSelector component +vi.mock("@/components/dashboards/addPanel/QueryTypeSelector.vue", () => ({ + default: { + name: "QueryTypeSelector", + template: '
QueryTypeSelector
', + }, +})); + +// Create i18n instance +const i18n = createI18n({ + legacy: false, + locale: "en", + messages: { + en: { + common: { + cancel: "Cancel", + apply: "Apply", + save: "Save", + }, + search: { + buildQuery: "Build Query", + }, + panel: { + addToDashboard: "Add to Dashboard", + }, + }, + }, +}); + +// Create a mount helper +function createWrapper(props = {}) { + return mount(BuildQueryPage, { + props: { + searchQuery: "", + selectedStream: "", + selectedDateTime: undefined, + ...props, + }, + global: { + plugins: [i18n, [Quasar, { plugins: { Dialog, Notify } }]], + provide: { + store: mockStore, + dashboardPanelDataPageKey: "build", + }, + stubs: { + PanelEditor: { + template: '
', + methods: { + runQuery: vi.fn(), + }, + }, + AddToDashboard: true, + QueryTypeSelector: true, + "q-dialog": true, + }, + }, + }); +} + +describe("BuildQueryPage Component", () => { + let wrapper: any; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset mock data + mockDashboardPanelData.data.queries[0].query = ""; + mockDashboardPanelData.data.queries[0].customQuery = false; + mockDashboardPanelData.data.queries[0].fields.stream = ""; + mockDashboardPanelData.data.type = "line"; + mockDashboardPanelData.data.config.table_dynamic_columns = false; + }); + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + describe("Rendering", () => { + it("should render the build query page container", async () => { + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.find(".build-query-page").exists()).toBe(true); + }); + + it("should render the PanelEditor component", async () => { + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.find('[data-test="panel-editor"]').exists()).toBe(true); + }); + + it("should have PanelEditor stub with correct test attribute", async () => { + wrapper = createWrapper(); + await flushPromises(); + + // The PanelEditor stub renders with data-test attribute + const panelEditorStub = wrapper.find('[data-test="panel-editor"]'); + expect(panelEditorStub.exists()).toBe(true); + expect(panelEditorStub.classes()).toContain("panel-editor-mock"); + }); + }); + + describe("Props Handling", () => { + it("should accept searchQuery prop", async () => { + const testQuery = 'SELECT * FROM "test_stream"'; + wrapper = createWrapper({ searchQuery: testQuery }); + await flushPromises(); + + expect(wrapper.props("searchQuery")).toBe(testQuery); + }); + + it("should accept selectedStream prop", async () => { + const testStream = "my_logs_stream"; + wrapper = createWrapper({ selectedStream: testStream }); + await flushPromises(); + + expect(wrapper.props("selectedStream")).toBe(testStream); + }); + + it("should accept selectedDateTime prop", async () => { + const testDateTime = { + start_time: new Date("2024-01-01"), + end_time: new Date("2024-01-02"), + valueType: "absolute", + }; + wrapper = createWrapper({ selectedDateTime: testDateTime }); + await flushPromises(); + + expect(wrapper.props("selectedDateTime")).toEqual(testDateTime); + }); + }); + + describe("Initialization", () => { + it("should call resetDashboardPanelData on mount", async () => { + wrapper = createWrapper(); + await flushPromises(); + + expect(mockResetDashboardPanelData).toHaveBeenCalled(); + }); + + it("should initialize with builder mode for empty query", async () => { + wrapper = createWrapper({ + searchQuery: "", + selectedStream: "test_stream", + }); + await flushPromises(); + + expect(mockDashboardPanelData.data.queries[0].customQuery).toBe(false); + }); + + it("should set stream from selectedStream prop when no query", async () => { + wrapper = createWrapper({ + searchQuery: "", + selectedStream: "my_logs_stream", + }); + await flushPromises(); + + expect(mockDashboardPanelData.data.queries[0].fields.stream).toBe("my_logs_stream"); + expect(mockDashboardPanelData.data.queries[0].fields.stream_type).toBe("logs"); + }); + + it("should call updateGroupedFields when stream is set", async () => { + wrapper = createWrapper({ + searchQuery: "", + selectedStream: "test_stream", + }); + await flushPromises(); + + expect(mockUpdateGroupedFields).toHaveBeenCalled(); + }); + }); + + describe("Query Parsing", () => { + it("should try to parse SQL query when provided", async () => { + const { parseSQL } = await import("@/utils/query/sqlQueryParser"); + + wrapper = createWrapper({ + searchQuery: 'SELECT * FROM "test_stream"', + selectedStream: "test_stream", + }); + await flushPromises(); + + expect(parseSQL).toHaveBeenCalled(); + }); + + it("should use custom mode for complex queries", async () => { + const { shouldUseCustomMode } = await import("@/utils/query/sqlQueryParser"); + (shouldUseCustomMode as any).mockReturnValueOnce(true); + + wrapper = createWrapper({ + searchQuery: 'SELECT * FROM (SELECT * FROM "test_stream")', + selectedStream: "test_stream", + }); + await flushPromises(); + + expect(mockDashboardPanelData.data.queries[0].customQuery).toBe(true); + }); + }); + + describe("Events", () => { + it("should forward queryGenerated event from PanelEditor", async () => { + wrapper = createWrapper({ + searchQuery: "", + selectedStream: "logs", + }); + await flushPromises(); + + // Find PanelEditor mock by data-test attribute and trigger event + const panelEditorMock = wrapper.find('[data-test="panel-editor"]'); + expect(panelEditorMock.exists()).toBe(true); + + // Trigger the event using Vue's event system + await wrapper.vm.$options.components?.PanelEditor?.methods?.runQuery?.(); + + // Directly call the onQueryGenerated handler to simulate PanelEditor emitting the event + // This tests that the forwarding logic works + const testQuery = "SELECT * FROM logs"; + // Access the internal handler via the component's setup + wrapper.vm.onQueryGenerated?.(testQuery) || + wrapper.vm.$emit("queryGenerated", testQuery); // Fallback + + await flushPromises(); + + // Check if queryGenerated was forwarded by BuildQueryPage + const emitted = wrapper.emitted("queryGenerated"); + expect(emitted).toBeTruthy(); + }); + + it("should forward customQueryModeChanged event from PanelEditor", async () => { + wrapper = createWrapper({ + searchQuery: "", + selectedStream: "logs", + }); + await flushPromises(); + + // Directly call the onCustomQueryModeChanged handler to simulate PanelEditor emitting the event + if (wrapper.vm.onCustomQueryModeChanged) { + wrapper.vm.onCustomQueryModeChanged(true); + } + await flushPromises(); + + // Check that BuildQueryPage forwards the event + const emitted = wrapper.emitted("customQueryModeChanged"); + expect(emitted).toBeTruthy(); + // Find the emitted event with value true + const hasTrue = emitted!.some((args: any[]) => args[0] === true); + expect(hasTrue).toBe(true); + }); + }); + + describe("Exposed Methods", () => { + it("should expose runQuery method", async () => { + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.runQuery).toBeDefined(); + expect(typeof wrapper.vm.runQuery).toBe("function"); + }); + + it("should expose panelEditorRef", async () => { + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.panelEditorRef).toBeDefined(); + }); + + it("should expose dashboardPanelData", async () => { + wrapper = createWrapper(); + await flushPromises(); + + expect(wrapper.vm.dashboardPanelData).toBeDefined(); + }); + }); + + describe("Add to Dashboard Dialog", () => { + it("should show add to dashboard dialog when triggered", async () => { + wrapper = createWrapper(); + await flushPromises(); + + // Check initial state + expect(wrapper.vm.showAddToDashboardDialog).toBe(false); + + // Trigger the dialog + wrapper.vm.showAddToDashboardDialog = true; + await nextTick(); + + expect(wrapper.vm.showAddToDashboardDialog).toBe(true); + }); + + it("should close add to dashboard dialog on addPanelToDashboard", async () => { + wrapper = createWrapper(); + await flushPromises(); + + wrapper.vm.showAddToDashboardDialog = true; + await nextTick(); + + // Call the handler + wrapper.vm.showAddToDashboardDialog = false; + await nextTick(); + + expect(wrapper.vm.showAddToDashboardDialog).toBe(false); + }); + }); + + describe("DateTime Handling", () => { + it("should sync datetime from props to dashboardPanelData", async () => { + const testDateTime = { + start_time: new Date("2024-06-01").getTime(), + end_time: new Date("2024-06-02").getTime(), + valueType: "absolute" as const, + }; + + wrapper = createWrapper({ + selectedDateTime: testDateTime, + }); + await flushPromises(); + + expect(mockDashboardPanelData.meta.dateTime).toEqual(testDateTime); + }); + + }); + + describe("Error Handling", () => { + it("should handle chart API errors gracefully", async () => { + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + wrapper = createWrapper(); + await flushPromises(); + + // Simulate chart API error + wrapper.vm.handleChartApiError("Test error"); + + expect(consoleErrorSpy).toHaveBeenCalledWith("Chart API error:", "Test error"); + + consoleErrorSpy.mockRestore(); + }); + + it("should fallback to custom mode on parse error", async () => { + const { parseSQL } = await import("@/utils/query/sqlQueryParser"); + (parseSQL as any).mockRejectedValueOnce(new Error("Parse error")); + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + wrapper = createWrapper({ + searchQuery: "INVALID SQL", + selectedStream: "test_stream", + }); + await flushPromises(); + + expect(mockDashboardPanelData.data.queries[0].customQuery).toBe(true); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe("Query Mode", () => { + it("should set customQuery to false for builder mode", async () => { + const { parseSQL } = await import("@/utils/query/sqlQueryParser"); + (parseSQL as any).mockResolvedValueOnce({ + customQuery: false, + stream: "test_stream", + }); + + wrapper = createWrapper({ + searchQuery: 'SELECT * FROM "test_stream"', + selectedStream: "test_stream", + }); + await flushPromises(); + + expect(mockDashboardPanelData.data.queries[0].customQuery).toBe(false); + }); + + it("should set table type with dynamic columns for custom mode", async () => { + const { shouldUseCustomMode } = await import("@/utils/query/sqlQueryParser"); + (shouldUseCustomMode as any).mockReturnValueOnce(true); + + wrapper = createWrapper({ + searchQuery: "complex query", + selectedStream: "test_stream", + }); + await flushPromises(); + + expect(mockDashboardPanelData.data.type).toBe("table"); + expect(mockDashboardPanelData.data.config.table_dynamic_columns).toBe(true); + }); + + it("should auto-select metric chart type when only Y-axis fields are present", async () => { + const { parsedQueryToPanelFields, shouldUseCustomMode, parseSQL } = await import("@/utils/query/sqlQueryParser"); + + // Mock parseSQL to return a valid parsed result + (parseSQL as any).mockResolvedValueOnce({ + stream: "test_stream", + streamType: "logs", + xFields: [], + yFields: [{ column: "_timestamp", alias: "y_axis_1", aggregationFunction: "count" }], + breakdownFields: [], + filters: { filterType: "group", logicalOperator: "AND", conditions: [] }, + customQuery: false, + rawQuery: 'SELECT count(_timestamp) as "y_axis_1" FROM "test_stream"', + }); + + // Mock parsedQueryToPanelFields to return only Y-axis field (no X, no breakdown) + (parsedQueryToPanelFields as any).mockReturnValueOnce({ + stream: "test_stream", + stream_type: "logs", + x: [], // No X-axis fields + y: [{ column: "_timestamp", alias: "y_axis_1", functionName: "count" }], // Has Y-axis field + breakdown: [], // No breakdown fields + filter: { filterType: "group", logicalOperator: "AND", conditions: [] }, + }); + + (shouldUseCustomMode as any).mockReturnValueOnce(false); + + wrapper = createWrapper({ + searchQuery: 'SELECT count(_timestamp) as "y_axis_1" FROM "test_stream"', + selectedStream: "test_stream", + }); + await flushPromises(); + + // Should auto-select "metric" chart type for Y-axis only queries + expect(mockDashboardPanelData.data.type).toBe("metric"); + }); + + it("should auto-select table chart type when zero Y-axis fields are present", async () => { + const { parsedQueryToPanelFields, shouldUseCustomMode, parseSQL } = await import("@/utils/query/sqlQueryParser"); + + // Mock parseSQL to return a valid parsed result with no Y-axis fields + (parseSQL as any).mockResolvedValueOnce({ + stream: "test_stream", + streamType: "logs", + xFields: [{ column: "method", alias: "x_axis_1", aggregationFunction: null }], + yFields: [], + breakdownFields: [], + filters: { filterType: "group", logicalOperator: "AND", conditions: [] }, + customQuery: false, + rawQuery: 'SELECT method FROM "test_stream"', + }); + + // Mock parsedQueryToPanelFields to return only X-axis field (no Y, no breakdown) + (parsedQueryToPanelFields as any).mockReturnValueOnce({ + stream: "test_stream", + stream_type: "logs", + x: [{ column: "method", alias: "x_axis_1", functionName: null }], + y: [], // No Y-axis fields + breakdown: [], + filter: { filterType: "group", logicalOperator: "AND", conditions: [] }, + }); + + (shouldUseCustomMode as any).mockReturnValueOnce(false); + + wrapper = createWrapper({ + searchQuery: 'SELECT method FROM "test_stream"', + selectedStream: "test_stream", + }); + await flushPromises(); + + // Should auto-select "table" chart type when no Y-axis fields + expect(mockDashboardPanelData.data.type).toBe("table"); + }); + }); + + describe("Run Query", () => { + it("should call PanelEditor runQuery for builder mode", async () => { + mockDashboardPanelData.data.queries[0].customQuery = false; + + wrapper = createWrapper({ + selectedStream: "test_stream", + }); + await flushPromises(); + + // Mock PanelEditor's runQuery method + const mockRunQuery = vi.fn(); + wrapper.vm.panelEditorRef = { runQuery: mockRunQuery }; + + await wrapper.vm.runQuery(); + await flushPromises(); + + // PanelEditor's runQuery should be called (BuildQueryPage delegates to PanelEditor) + expect(mockRunQuery).toHaveBeenCalled(); + }); + + it("should call PanelEditor runQuery for custom mode", async () => { + mockDashboardPanelData.data.queries[0].customQuery = true; + + wrapper = createWrapper({ + selectedStream: "test_stream", + }); + await flushPromises(); + + // Get PanelEditor ref and mock its runQuery + const mockRunQuery = vi.fn(); + wrapper.vm.panelEditorRef = { runQuery: mockRunQuery }; + + await wrapper.vm.runQuery(); + await flushPromises(); + + // PanelEditor's runQuery should be called for custom queries too + expect(mockRunQuery).toHaveBeenCalled(); + }); + + it("should update stream fields before running query in builder mode", async () => { + mockDashboardPanelData.data.queries[0].customQuery = false; + mockDashboardPanelData.meta.streamFields = { groupedFields: [] }; + mockUpdateGroupedFields.mockClear(); + + wrapper = createWrapper({ + selectedStream: "test_stream", + }); + await flushPromises(); + + // Mock panelEditorRef + wrapper.vm.panelEditorRef = { runQuery: vi.fn() }; + + await wrapper.vm.runQuery(); + await flushPromises(); + + expect(mockUpdateGroupedFields).toHaveBeenCalled(); + }); + }); +}); + +describe("BuildQueryPage Component - Integration Tests", () => { + let wrapper: any; + + beforeEach(() => { + vi.clearAllMocks(); + mockDashboardPanelData.data.queries[0].query = ""; + mockDashboardPanelData.data.queries[0].customQuery = false; + mockDashboardPanelData.data.queries[0].fields.stream = ""; + }); + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + } + }); + + it("should handle full workflow: select stream, build query, apply", async () => { + const { parseSQL, parsedQueryToPanelFields } = await import("@/utils/query/sqlQueryParser"); + + // Mock successful parse + (parseSQL as any).mockResolvedValueOnce({ + customQuery: false, + stream: "logs", + }); + (parsedQueryToPanelFields as any).mockReturnValueOnce({ + stream: "logs", + stream_type: "logs", + x: [{ column: "_timestamp" }], + y: [{ column: "count" }], + breakdown: [], + filter: [], + }); + + wrapper = createWrapper({ + searchQuery: "", + selectedStream: "logs", + }); + await flushPromises(); + + // Verify initialization + expect(mockDashboardPanelData.data.queries[0].fields.stream).toBe("logs"); + + // Mock PanelEditor's runQuery method + const mockRunQuery = vi.fn(); + wrapper.vm.panelEditorRef = { runQuery: mockRunQuery }; + + // Run query (this delegates to PanelEditor) + await wrapper.vm.runQuery(); + await flushPromises(); + + // Verify PanelEditor's runQuery was called + expect(mockRunQuery).toHaveBeenCalled(); + + // Simulate PanelEditor emitting queryGenerated via the onQueryGenerated handler + const generatedQuery = 'SELECT histogram(_timestamp) FROM "logs"'; + if (wrapper.vm.onQueryGenerated) { + wrapper.vm.onQueryGenerated(generatedQuery); + } + await flushPromises(); + + // Verify query event was forwarded by BuildQueryPage + const emitted = wrapper.emitted("queryGenerated"); + expect(emitted).toBeTruthy(); + expect(emitted![emitted!.length - 1]).toEqual([generatedQuery]); + }); + +}); diff --git a/web/src/plugins/logs/BuildQueryPage.vue b/web/src/plugins/logs/BuildQueryPage.vue new file mode 100644 index 0000000000..3cab215a90 --- /dev/null +++ b/web/src/plugins/logs/BuildQueryPage.vue @@ -0,0 +1,458 @@ + + + + + + + diff --git a/web/src/plugins/logs/Index.spec.ts b/web/src/plugins/logs/Index.spec.ts index ff9f57cc52..432707c3f4 100644 --- a/web/src/plugins/logs/Index.spec.ts +++ b/web/src/plugins/logs/Index.spec.ts @@ -1029,4 +1029,129 @@ describe("Logs Index", async () => { expect(typeof wrapper.vm.extractPatternsForCurrentQuery).toBe("function"); }); }); + + /** + * Build Query Page Support Tests - Index.vue + * PR Reference: https://github.com/openobserve/openobserve/pull/10305 + * + * Tests for the new Build Query Page toggle feature: + * 1. Build toggle visibility and interaction + * 2. Build page handlers (onBuildApply, onBuildCancel, onBuildQueryGenerated) + * 3. selectedDateTime computed property + * 4. BuildQueryPage ref and integration + */ + describe("Build Query Page Support", () => { + describe("Build toggle state", () => { + it("should have logsVisualizeToggle initialized to 'logs'", () => { + expect(wrapper.vm.searchObj.meta.logsVisualizeToggle).toBe("logs"); + }); + + it("should update toggle state to 'build'", async () => { + wrapper.vm.searchObj.meta.logsVisualizeToggle = "build"; + await flushPromises(); + expect(wrapper.vm.searchObj.meta.logsVisualizeToggle).toBe("build"); + }); + + it("should show logs view when toggle is 'logs'", async () => { + wrapper.vm.searchObj.meta.logsVisualizeToggle = "logs"; + await flushPromises(); + // The thirdLevel section should be visible for 'logs' toggle + const thirdLevel = wrapper.find('#thirdLevel'); + expect(thirdLevel.exists()).toBe(true); + }); + + it("should allow switching to 'build' mode", async () => { + // Switch to build mode + wrapper.vm.searchObj.meta.logsVisualizeToggle = "build"; + await flushPromises(); + + // Build mode should be set + expect(wrapper.vm.searchObj.meta.logsVisualizeToggle).toBe("build"); + }); + }); + + describe("Build page handlers", () => { + it("should have onBuildApply handler defined", () => { + expect(wrapper.vm.onBuildApply).toBeDefined(); + expect(typeof wrapper.vm.onBuildApply).toBe("function"); + }); + + it("should update query and switch to logs on onBuildApply", async () => { + wrapper.vm.searchObj.meta.logsVisualizeToggle = "build"; + await flushPromises(); + + const testQuery = 'SELECT * FROM "test_stream"'; + wrapper.vm.onBuildApply(testQuery); + await flushPromises(); + + expect(wrapper.vm.searchObj.data.query).toBe(testQuery); + expect(wrapper.vm.searchObj.meta.logsVisualizeToggle).toBe("logs"); + }); + + it("should have onBuildCancel handler defined", () => { + expect(wrapper.vm.onBuildCancel).toBeDefined(); + expect(typeof wrapper.vm.onBuildCancel).toBe("function"); + }); + + it("should switch to logs view on onBuildCancel", async () => { + wrapper.vm.searchObj.meta.logsVisualizeToggle = "build"; + await flushPromises(); + + wrapper.vm.onBuildCancel(); + await flushPromises(); + + expect(wrapper.vm.searchObj.meta.logsVisualizeToggle).toBe("logs"); + }); + + it("should have onBuildQueryGenerated handler defined", () => { + expect(wrapper.vm.onBuildQueryGenerated).toBeDefined(); + expect(typeof wrapper.vm.onBuildQueryGenerated).toBe("function"); + }); + + it("should sync generated query on onBuildQueryGenerated", async () => { + const testQuery = 'SELECT histogram(_timestamp) FROM "logs"'; + wrapper.vm.onBuildQueryGenerated(testQuery); + await flushPromises(); + + expect(wrapper.vm.searchObj.data.query).toBe(testQuery); + }); + + it("should not update query if onBuildQueryGenerated receives empty string", async () => { + const originalQuery = wrapper.vm.searchObj.data.query; + wrapper.vm.onBuildQueryGenerated(""); + await flushPromises(); + + // Query should remain unchanged when empty string is passed + expect(wrapper.vm.searchObj.data.query).toBe(originalQuery); + }); + }); + + describe("selectedDateTime computed", () => { + it("should have selectedDateTime computed property defined", () => { + expect(wrapper.vm.selectedDateTime).toBeDefined(); + }); + + it("should return datetime object with start_time and end_time", () => { + const dateTime = wrapper.vm.selectedDateTime; + expect(dateTime).toHaveProperty("start_time"); + expect(dateTime).toHaveProperty("end_time"); + }); + + it("should include valueType in selectedDateTime", () => { + const dateTime = wrapper.vm.selectedDateTime; + expect(dateTime).toHaveProperty("valueType"); + }); + + it("should include relativeTimePeriod in selectedDateTime", () => { + const dateTime = wrapper.vm.selectedDateTime; + expect(dateTime).toHaveProperty("relativeTimePeriod"); + }); + }); + + describe("BuildQueryPage ref", () => { + it("should have buildQueryPageRef defined", () => { + expect(wrapper.vm.buildQueryPageRef).toBeDefined(); + }); + }); + }); }); diff --git a/web/src/plugins/logs/Index.vue b/web/src/plugins/logs/Index.vue index bc5f494854..983892913c 100644 --- a/web/src/plugins/logs/Index.vue +++ b/web/src/plugins/logs/Index.vue @@ -27,6 +27,7 @@ along with this program. If not, see . class="logs-horizontal-splitter full-height" v-model="splitterModel" horizontal + @update:model-value="onSplitterUpdate" > @@ -52,7 +54,7 @@ along with this program. If not, see .
. :searchResponse="searchResponseForVisualization" :is_ui_histogram="shouldUseHistogramQuery" :shouldRefreshWithoutCache="shouldRefreshWithoutCache" + :histogramQuery="storedHistogramQuery" >
+
+ +
@@ -465,6 +487,9 @@ export default defineComponent({ VisualizeLogsQuery: defineAsyncComponent( () => import("@/plugins/logs/VisualizeLogsQuery.vue"), ), + BuildQueryPage: defineAsyncComponent( + () => import("@/plugins/logs/BuildQueryPage.vue"), + ), SearchHistory: defineAsyncComponent( () => import("@/plugins/logs/SearchHistory.vue"), ), @@ -663,6 +688,7 @@ export default defineComponent({ const searchResultRef = ref(null); const searchBarRef = ref(null); + const buildQueryPageRef = ref(null); const showSearchHistory = ref(false); const showSearchScheduler = ref(false); const showJobScheduler = ref(false); @@ -690,6 +716,14 @@ export default defineComponent({ convertSchemaToFields, setFieldsBasedOnChartTypeValidation, } = useDashboardPanelData("logs"); + + // Get build page's dashboardPanelData for watching chart type/config changes + const { + dashboardPanelData: buildDashboardPanelData, + removeXYFilters: buildRemoveXYFilters, + updateXYFieldsForCustomQueryMode: buildUpdateXYFieldsForCustomQueryMode, + } = useDashboardPanelData("build"); + const visualizeErrorData: any = reactive({ errors: [], }); @@ -1577,6 +1611,20 @@ export default defineComponent({ const shouldUseHistogramQuery = ref(false); const shouldRefreshWithoutCache = ref(false); + // Store the histogram query so it persists even after searchResponse is cleared + const storedHistogramQuery = ref(""); + + // Watch for histogram query in search results and store it immediately + // This ensures the histogram query is saved before queryResults might be reset + watch( + () => searchObj.data.queryResults?.converted_histogram_query, + (newHistogramQuery) => { + if (newHistogramQuery) { + storedHistogramQuery.value = newHistogramQuery; + } + }, + { immediate: true } + ); // Flag to prevent unnecessary chart type changes during URL restoration const isRestoringFromUrl = ref(false); @@ -1584,10 +1632,34 @@ export default defineComponent({ // Flag to track if this is the first time switching to visualization mode const isFirstVisualizationToggle = ref(true); + // Flag to track if this is the first time switching to build mode + // Used to restore chart type from URL only on first toggle (for shared links) + const isFirstBuildToggle = ref(true); + watch( () => [searchObj?.meta?.logsVisualizeToggle], async () => { try { + // Reset buildModeQueryEditorDisabled when not in build mode + if (searchObj.meta.logsVisualizeToggle !== "build") { + searchObj.meta.buildModeQueryEditorDisabled = false; + } + + // Set loading flag for build mode with SQL mode ON to prevent flicker between initialization and chart API call + // This will be cleared when trace IDs arrive (via watcher) or when unmounting + // When SQL mode is OFF, build page handles its own loading state + if (searchObj.meta.logsVisualizeToggle === "build" && searchObj.meta.sqlMode) { + variablesAndPanelsDataLoadingState.fieldsExtractionLoading = true; + + // Check for empty query - no API call will be made, so clear loading flag + if (!searchObj.data.query?.trim()) { + showErrorNotification( + "Query is empty, please select fields to build query", + ); + variablesAndPanelsDataLoadingState.fieldsExtractionLoading = false; + } + } + if (searchObj.meta.logsVisualizeToggle == "visualize") { // Enable quick mode automatically when switching to visualization if: // 1. SQL mode is disabled OR @@ -1770,6 +1842,10 @@ export default defineComponent({ // assign converted_histogram_query to dashboardPanelData if (searchObj.data.queryResults.converted_histogram_query) { + // Store the histogram query so it persists for "Add to Dashboard" + storedHistogramQuery.value = + searchObj.data.queryResults.converted_histogram_query; + dashboardPanelData.data.queries[ dashboardPanelData.layout.currentQueryIndex ].query = @@ -1858,8 +1934,13 @@ export default defineComponent({ await nextTick(); isRestoringFromUrl.value = false; - // set fields extraction loading to false - variablesAndPanelsDataLoadingState.fieldsExtractionLoading = false; + // Only clear fieldsExtractionLoading if we have data to reuse (no API call needed) + // If searchResponseForVisualization has hits, data will be reused and no API call + // If empty, API call will happen and trace IDs watcher will clear the flag + const hasDataToReuse = searchResponseForVisualization.value?.hits?.length > 0; + if (hasDataToReuse) { + variablesAndPanelsDataLoadingState.fieldsExtractionLoading = false; + } // emit resize event // this will rerender/call resize method of already rendered chart to resize @@ -1978,6 +2059,17 @@ export default defineComponent({ }, ); + // Watch for build page chart type changes to sync URL params + watch( + () => buildDashboardPanelData.data.type, + () => { + // Sync build data to URL parameters when chart type changes + if (searchObj.meta.logsVisualizeToggle === "build") { + updateUrlQueryParams(null, buildDashboardPanelData); + } + }, + ); + watch( () => splitterModel.value, () => { @@ -2005,6 +2097,33 @@ export default defineComponent({ deep: true, }); + // Watch for build page config changes to sync URL params + watch( + () => buildDashboardPanelData.data.config, + () => { + if (searchObj.meta.logsVisualizeToggle === "build") { + updateUrlQueryParams(null, buildDashboardPanelData); + } + }, + { deep: true }, + ); + + // Sync searchObj.data.query to build page's dashboardPanelData when in custom query mode + // This ensures edited queries are reflected in the panel schema immediately + watch( + () => searchObj.data.query, + (newQuery) => { + if ( + searchObj.meta.logsVisualizeToggle === "build" && + buildDashboardPanelData.data.queries[0]?.customQuery === true + ) { + buildDashboardPanelData.data.queries[ + buildDashboardPanelData.layout.currentQueryIndex + ].query = newQuery; + } + }, + ); + watch( () => [ searchObj.data.datetime.type, @@ -2133,6 +2252,38 @@ export default defineComponent({ // Extract patterns when user clicks run query in patterns mode await extractPatternsForCurrentQuery(clear_cache); } + + if (searchObj.meta.logsVisualizeToggle == "build") { + // Validate query before running - same as visualization mode + if (searchObj.meta.sqlMode && !searchObj.data.query?.trim()) { + showErrorNotification( + "Query is empty, please select fields to build query", + ); + return; + } + + // Run query in build mode - same approach as visualization + const dateTime = + searchObj.data.datetime.type === "relative" + ? getConsumableRelativeTime( + searchObj.data.datetime.relativeTimePeriod, + ) + : cloneDeep(searchObj.data.datetime); + + // Set datetime in build page's dashboardPanelData (same as visualization) + if (buildQueryPageRef.value?.dashboardPanelData) { + buildQueryPageRef.value.dashboardPanelData.meta.dateTime = { + start_time: new Date(dateTime.startTime), + end_time: new Date(dateTime.endTime), + }; + } + + // Trigger PanelEditor's runQuery + buildQueryPageRef.value?.runQuery(clear_cache); + + // Sync build config to URL parameters + updateUrlQueryParams(null, buildQueryPageRef.value?.dashboardPanelData); + } }; const handleChartApiError = (errorMessage: any) => { @@ -2141,6 +2292,85 @@ export default defineComponent({ errorList.push(errorMessage); }; + // Build Query Page handlers + const onBuildApply = (query: string) => { + // Apply the generated query from build page + searchObj.data.query = query; + searchObj.meta.logsVisualizeToggle = "logs"; + handleRunQueryFn(); + }; + + const onBuildCancel = () => { + // Cancel and return to logs view + searchObj.meta.logsVisualizeToggle = "logs"; + }; + + const onBuildQueryGenerated = (query: string) => { + // Sync generated query to logs composables so user can see it in the editor + // Always update, including empty string when all fields are removed + searchObj.data.query = query; + searchObj.data.editorValue = query; + }; + + const onCustomQueryModeChanged = (isCustomMode: boolean) => { + // Disable query editor in build mode when customQuery is false (builder mode) + // In builder mode, query is auto-generated from fields - user shouldn't edit directly + searchObj.meta.buildModeQueryEditorDisabled = !isCustomMode; + }; + + // Handle build mode toggle from SearchBar (updates panel schema's customQuery) + const onBuildModeToggle = async (isCustomMode: boolean) => { + // Update the panel schema's customQuery flag + if (buildDashboardPanelData.data.queries[0]) { + buildDashboardPanelData.data.queries[0].customQuery = isCustomMode; + + // Reuse the same logic as QueryTypeSelector's changeToggle: + // clear fields and query when switching modes + await nextTick(); + buildRemoveXYFilters(); + buildUpdateXYFieldsForCustomQueryMode(); + + // Clear query when switching from Custom to Builder mode + if (!isCustomMode) { + buildDashboardPanelData.data.queries[ + buildDashboardPanelData.layout.currentQueryIndex + ].query = ""; + // Also clear the search bar editor + searchObj.data.query = ""; + searchObj.data.editorValue = ""; + } + } + }; + + const onBuildInitialized = () => { + // Mark that we've processed the first build toggle + // After this, chart type will always be auto-selected on tab switch + if (isFirstBuildToggle.value) { + isFirstBuildToggle.value = false; + } + + // Clear fields extraction loading flag since build page has finished initialization + // This prevents the cancel button from staying visible when no query is actually running + variablesAndPanelsDataLoadingState.fieldsExtractionLoading = false; + }; + + // Selected date time for BuildQueryPage + const selectedDateTime = computed(() => { + const dateTime = + searchObj.data.datetime.type === "relative" + ? getConsumableRelativeTime( + searchObj.data.datetime.relativeTimePeriod, + ) + : cloneDeep(searchObj.data.datetime); + + return { + start_time: new Date(dateTime.startTime), + end_time: new Date(dateTime.endTime), + valueType: searchObj.data.datetime.type, + relativeTimePeriod: searchObj.data.datetime.relativeTimePeriod, + }; + }); + // [START] cancel running queries //reactive object for loading state of variablesData and panels @@ -2255,7 +2485,6 @@ export default defineComponent({ } else { logsPageQuery = searchObj.data.query; } - // return if query is empty and stream is not selected if ( logsPageQuery === "" && @@ -2395,6 +2624,11 @@ export default defineComponent({ await copyDashboardDataToVisualize(); + // Don't clear fieldsExtractionLoading here - let the watcher handle it. + // The watcher will clear it when: + // 1. Trace IDs appear (API call started) - existing watcher at line ~2630 + // 2. Chart data is set AND no trace IDs (data reused) - new watcher below + return shouldUseHistogramQuery.value; } catch (err) { visualizeErrorData.errors.splice(0); @@ -2655,10 +2889,20 @@ export default defineComponent({ searchResponseForVisualization, shouldUseHistogramQuery, shouldRefreshWithoutCache, + storedHistogramQuery, clearSchemaCache, getHistogramData, extractPatternsForCurrentQuery, patternsState, + buildQueryPageRef, + onBuildApply, + onBuildCancel, + onBuildQueryGenerated, + onCustomQueryModeChanged, + onBuildModeToggle, + onBuildInitialized, + selectedDateTime, + isFirstBuildToggle, }; }, computed: { diff --git a/web/src/plugins/logs/SearchBar.spec.ts b/web/src/plugins/logs/SearchBar.spec.ts index 2dab2f3d83..860b6f38ca 100644 --- a/web/src/plugins/logs/SearchBar.spec.ts +++ b/web/src/plugins/logs/SearchBar.spec.ts @@ -3560,6 +3560,165 @@ describe("SearchBar.vue VRL Visualization Support", () => { }); }); +/** + * Build Query Toggle Support Tests - SearchBar.vue + * PR Reference: https://github.com/openobserve/openobserve/pull/10305 + * + * Tests for the new Build Query toggle button feature: + * 1. Build toggle button rendering + * 2. Build toggle state changes + * 3. handleRunQueryFn behavior with 'build' toggle + * 4. Integration with logsVisualizeToggle + */ +describe("SearchBar.vue Build Query Toggle Support", () => { + let buildInstance: any; + + beforeEach(() => { + buildInstance = { + searchObj: { + meta: { + logsVisualizeToggle: "logs", + sqlMode: true, + }, + data: { + stream: { + selectedStream: ["test-stream"], + }, + query: 'SELECT * FROM "test-stream"', + }, + }, + + // Mock event handler + onLogsVisualizeToggleUpdate: vi.fn((value: string) => { + buildInstance.searchObj.meta.logsVisualizeToggle = value; + }), + + // Mock emit + emit: vi.fn(), + + // Mock handleRunQueryFn + handleRunQueryFn: vi.fn((clear_cache = false) => { + if ( + buildInstance.searchObj.meta.logsVisualizeToggle === "visualize" || + buildInstance.searchObj.meta.logsVisualizeToggle === "patterns" || + buildInstance.searchObj.meta.logsVisualizeToggle === "build" + ) { + buildInstance.emit("handleRunQueryFn", typeof clear_cache === "boolean" ? clear_cache : false); + } + }), + }; + }); + + describe("Build toggle state management", () => { + it("should have logsVisualizeToggle initialized to 'logs'", () => { + expect(buildInstance.searchObj.meta.logsVisualizeToggle).toBe("logs"); + }); + + it("should update logsVisualizeToggle to 'build' on toggle click", () => { + buildInstance.onLogsVisualizeToggleUpdate("build"); + expect(buildInstance.searchObj.meta.logsVisualizeToggle).toBe("build"); + }); + + it("should support all toggle values: logs, visualize, build, patterns", () => { + const validToggleValues = ["logs", "visualize", "build", "patterns"]; + + validToggleValues.forEach(value => { + buildInstance.onLogsVisualizeToggleUpdate(value); + expect(buildInstance.searchObj.meta.logsVisualizeToggle).toBe(value); + }); + }); + + it("should toggle between logs and build modes", () => { + // Start at logs + expect(buildInstance.searchObj.meta.logsVisualizeToggle).toBe("logs"); + + // Toggle to build + buildInstance.onLogsVisualizeToggleUpdate("build"); + expect(buildInstance.searchObj.meta.logsVisualizeToggle).toBe("build"); + + // Toggle back to logs + buildInstance.onLogsVisualizeToggleUpdate("logs"); + expect(buildInstance.searchObj.meta.logsVisualizeToggle).toBe("logs"); + }); + }); + + describe("handleRunQueryFn with build mode", () => { + it("should emit handleRunQueryFn when toggle is 'build'", () => { + buildInstance.searchObj.meta.logsVisualizeToggle = "build"; + buildInstance.handleRunQueryFn(false); + + expect(buildInstance.emit).toHaveBeenCalledWith("handleRunQueryFn", false); + }); + + it("should emit handleRunQueryFn when toggle is 'visualize'", () => { + buildInstance.searchObj.meta.logsVisualizeToggle = "visualize"; + buildInstance.handleRunQueryFn(true); + + expect(buildInstance.emit).toHaveBeenCalledWith("handleRunQueryFn", true); + }); + + it("should emit handleRunQueryFn when toggle is 'patterns'", () => { + buildInstance.searchObj.meta.logsVisualizeToggle = "patterns"; + buildInstance.handleRunQueryFn(false); + + expect(buildInstance.emit).toHaveBeenCalledWith("handleRunQueryFn", false); + }); + + it("should NOT emit handleRunQueryFn when toggle is 'logs'", () => { + buildInstance.searchObj.meta.logsVisualizeToggle = "logs"; + buildInstance.handleRunQueryFn(false); + + expect(buildInstance.emit).not.toHaveBeenCalled(); + }); + + it("should pass clear_cache parameter correctly to emit", () => { + buildInstance.searchObj.meta.logsVisualizeToggle = "build"; + + buildInstance.handleRunQueryFn(true); + expect(buildInstance.emit).toHaveBeenCalledWith("handleRunQueryFn", true); + + buildInstance.emit.mockClear(); + + buildInstance.handleRunQueryFn(false); + expect(buildInstance.emit).toHaveBeenCalledWith("handleRunQueryFn", false); + }); + }); + + describe("Build toggle button visibility", () => { + it("should track selected state based on logsVisualizeToggle value", () => { + const isSelected = () => buildInstance.searchObj.meta.logsVisualizeToggle === "build"; + + expect(isSelected()).toBe(false); + + buildInstance.onLogsVisualizeToggleUpdate("build"); + expect(isSelected()).toBe(true); + + buildInstance.onLogsVisualizeToggleUpdate("logs"); + expect(isSelected()).toBe(false); + }); + }); + + describe("Build mode interaction with query state", () => { + it("should preserve query when switching to build mode", () => { + const originalQuery = 'SELECT * FROM "test-stream"'; + buildInstance.searchObj.data.query = originalQuery; + + buildInstance.onLogsVisualizeToggleUpdate("build"); + + expect(buildInstance.searchObj.data.query).toBe(originalQuery); + }); + + it("should preserve stream selection when switching to build mode", () => { + const originalStream = ["test-stream"]; + buildInstance.searchObj.data.stream.selectedStream = originalStream; + + buildInstance.onLogsVisualizeToggleUpdate("build"); + + expect(buildInstance.searchObj.data.stream.selectedStream).toEqual(originalStream); + }); + }); +}); + /** * VRL Editor Disabled for Non-Table Charts Tests * PR Reference: https://github.com/openobserve/openobserve/pull/10343 diff --git a/web/src/plugins/logs/SearchBar.vue b/web/src/plugins/logs/SearchBar.vue index ad583c22af..db4d7a6a4b 100644 --- a/web/src/plugins/logs/SearchBar.vue +++ b/web/src/plugins/logs/SearchBar.vue @@ -48,7 +48,7 @@ along with this program. If not, see . data-test="logs-visualize-toggle" :class="[ searchObj.meta.logsVisualizeToggle === 'visualize' ? 'selected' : '', - config.isEnterprise == 'true' ? 'button button-center tw:rounded-none' : 'button button-right tw:rounded-l-none!', + 'button button-center tw:rounded-none', 'tw:flex tw:justify-center tw:items-center no-border no-outline q-px-sm btn-height-32' ]" @click="onLogsVisualizeToggleUpdate('visualize')" @@ -65,6 +65,25 @@ along with this program. If not, see . +
+ + + + {{ t("search.buildQuery") }} + + +
/> -
+
>