fix: refactor!: remove SQL mode toggle in favor of auto-detection (#12483)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Shrinath Rao <shnath@openobserve.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bhargav
2026-06-07 04:43:00 +00:00
committed by GitHub
co-authored by github-actions[bot] Shrinath Rao Claude Opus 4.7
parent 5eb63395f1
commit 61aebff552
60 changed files with 2705 additions and 1251 deletions
@@ -183,14 +183,9 @@ export default class LogsVisualise {
//enable SQL Mode
async enableSQLMode() {
// SQL mode toggle is inside the utilities menu dropdown
const utilitiesBtn = this.page.locator('[data-test="logs-search-bar-utilities-menu-btn"]');
await utilitiesBtn.waitFor({ state: "visible", timeout: 10000 });
await utilitiesBtn.click();
const sqlModeBtn = this.page.locator('[data-test="logs-search-bar-menu-sql-mode-btn"]');
await sqlModeBtn.waitFor({ state: "visible", timeout: 5000 });
await sqlModeBtn.click();
await this.page.keyboard.press("Escape");
// SQL mode toggle was removed from the UI. SQL mode is now auto-detected
// from query content — entering a SELECT query enables it automatically.
// This method is intentionally a no-op.
}
//stream index list
@@ -235,8 +235,16 @@ export class SanityPage {
// Histogram Methods
// ================================================================
async clickHistogramToggle() {
// Histogram toggle is inside the utilities ODropdown in SearchBar.vue.
// Must open the menu first before the OSwitch element exists in the DOM.
// Histogram has an inline toolbar button at normal viewport widths
// (data-test="logs-search-bar-histogram-btn"). It moves into the
// utilities ("More") menu only at narrow viewports (shouldMoveButtonsToMenu).
const inlineBtn = this.page.locator('[data-test="logs-search-bar-histogram-btn"]');
const isInline = await inlineBtn.isVisible({ timeout: 1000 }).catch(() => false);
if (isInline) {
await inlineBtn.click();
return;
}
// Narrow-viewport fallback: open utilities menu and click the menu item.
const menuHistogramBtn = this.page.locator('[data-test="logs-search-bar-menu-histogram-btn"]');
const isMenuItemVisible = await menuHistogramBtn.isVisible({ timeout: 500 }).catch(() => false);
if (!isMenuItemVisible) {
@@ -250,9 +258,18 @@ export class SanityPage {
// Returns true when the histogram OSwitch is currently ON.
// OSwitch surfaces its state via a nested `role="switch"` element carrying
// `aria-checked="true|false"` — opens the utilities menu to read the state,
// then closes it without triggering a toggle.
// `aria-checked="true|false"`. At normal viewport widths the histogram button
// is inline; at narrow widths it lives inside the utilities menu.
async isHistogramOn() {
// Check the inline histogram button first (normal viewport).
const inlineBtn = this.page.locator('[data-test="logs-search-bar-histogram-btn"]');
const isInline = await inlineBtn.isVisible({ timeout: 1000 }).catch(() => false);
if (isInline) {
const switchControl = inlineBtn.locator('[role="switch"]');
const aria = await switchControl.getAttribute('aria-checked');
return aria === 'true';
}
// Narrow-viewport fallback: read state from the menu item OSwitch.
const menuHistogramBtn = this.page.locator('[data-test="logs-search-bar-menu-histogram-btn"]');
const isMenuItemVisible = await menuHistogramBtn.isVisible({ timeout: 500 }).catch(() => false);
if (!isMenuItemVisible) {
@@ -295,20 +312,17 @@ export class SanityPage {
// ================================================================
/**
* Open the utilities ("More") dropdown, click the SQL mode ODropdownItem to toggle it,
* then close the dropdown.
* Uses [data-test="logs-search-bar-menu-sql-mode-btn"] — NOT the legacy syntax-guide element.
* Enable SQL mode. The SQL mode toggle was removed from the UI; the app now
* auto-detects SQL mode when the query editor contains both "select" and "from".
* This helper types a minimal SELECT query to trigger that auto-detection.
* Callers that subsequently call MonacoEditorHelper.setContent() will overwrite
* this query — SQL mode stays active because the replacement also contains SELECT.
*/
async _clickSqlModeToggle() {
const sqlModeMenuItem = this.page.locator('[data-test="logs-search-bar-menu-sql-mode-btn"]');
const isVisible = await sqlModeMenuItem.isVisible({ timeout: 500 }).catch(() => false);
if (!isVisible) {
await this.utilitiesMenuButton.click();
await sqlModeMenuItem.waitFor({ state: 'visible', timeout: 5000 });
}
await sqlModeMenuItem.click();
await this.page.keyboard.press('Escape');
await this.page.waitForTimeout(100);
await expect(this.queryEditor).toBeVisible({ timeout: 10000 });
const monacoHelper = new MonacoEditorHelper(this.page);
await monacoHelper.setContent(this.queryEditor, 'SELECT * FROM "e2e_automate"');
await this.page.waitForTimeout(200);
}
// ================================================================
+508 -186
View File
@@ -26,7 +26,8 @@ export class LogsPage {
this.homeButton = "[name ='home']";
this.queryButton = "[data-test='logs-search-bar-refresh-btn']";
this.queryEditor = '[data-test="logs-search-bar-query-editor"]';
this.quickModeToggle = '[data-test="logs-search-bar-quick-mode-toggle-btn"]';
// Post UX-revamp: renamed from logs-search-bar-quick-mode-toggle-btn
this.quickModeToggle = '[data-test="logs-search-bar-menu-quick-mode-toggle-btn"]';
// FieldListPagination schema-toggle buttons (data-test set per-slot in FieldListPagination.vue).
// The common FieldListPagination renders with `${dataTestPrefix}-all-fields-btn` etc.
// For the logs sidebar prefix is `logs-page`. Legacy non-prefixed variants kept as fallback
@@ -1384,6 +1385,10 @@ export class LogsPage {
async executeHistogramQuery(query) {
await this.clearAndFillQueryEditor(query);
// Wait for the 500ms debounce in CodeQueryEditor so the Vue watcher in
// SearchBar.vue can auto-detect SQL mode from the SELECT … FROM content
// before the query button is clicked.
await this.page.waitForTimeout(600);
await this.page.locator(this.queryButton).click();
// Wait for query execution and results to load
@@ -1480,6 +1485,8 @@ export class LogsPage {
}
// SQL Mode methods
// SQL mode toggle was removed from the UI. The editor now auto-detects SQL mode
// when the query contains SELECT...FROM. Delegate to the canonical enableSqlModeIfNeeded.
async enableSQLMode() {
await this.enableSqlModeIfNeeded();
}
@@ -1513,7 +1520,7 @@ export class LogsPage {
// Click on the Quick Mode toggle menu item (wrapper ODropdownItem) - for testing #10821
async clickQuickModeTextLabel() {
const quickModeItem = this.page.locator('[data-test="logs-search-bar-quick-mode-toggle-btn"]');
const quickModeItem = this.page.locator('[data-test="logs-search-bar-menu-quick-mode-toggle-btn"]');
await this.page.locator(this.utilitiesMenuButton).click();
await quickModeItem.waitFor({ state: 'visible', timeout: 5000 });
// Click immediately — no fixed delay avoids reka-ui focus-outside closing the portal in CI.
@@ -1536,8 +1543,17 @@ export class LogsPage {
// Histogram methods
async toggleHistogram() {
// Histogram toggle is now inside the utilities ("More") menu.
// Histogram is a standalone toolbar button (data-test="logs-search-bar-histogram-btn")
// in normal-width viewports. It falls back into the utilities ("More") dropdown only
// when the viewport is very narrow (shouldMoveButtonsToMenu breakpoint < 328px).
await this.page.keyboard.press('Escape').catch(() => {});
const inlineBtn = this.page.locator('[data-test="logs-search-bar-histogram-btn"]');
const isInline = await inlineBtn.isVisible({ timeout: 2000 }).catch(() => false);
if (isInline) {
await inlineBtn.click();
return;
}
// Narrow-viewport fallback: open the utilities menu and click the menu item.
await this.page.locator(this.utilitiesMenuButton).click();
const histogramMenuItem = this.page.locator(this.menuHistogramBtn);
await histogramMenuItem.waitFor({ state: 'visible', timeout: 5000 });
@@ -1570,13 +1586,24 @@ export class LogsPage {
}
async verifyHistogramState() {
// Histogram is now inside the utilities menu — open the menu and check the switch is unchecked.
// Verifies histogram is OFF (unchecked). Works in both normal and narrow viewports.
await this.page.keyboard.press('Escape').catch(() => {});
const inlineBtn = this.page.locator('[data-test="logs-search-bar-histogram-btn"]');
const isInline = await inlineBtn.isVisible({ timeout: 2000 }).catch(() => false);
if (isInline) {
// Normal viewport: the OSwitch inner button carries data-state="unchecked" when OFF.
const switchUnchecked = inlineBtn.locator('[data-state="unchecked"]');
await expect(switchUnchecked).toBeVisible({ timeout: 5000 });
return;
}
// Narrow-viewport fallback: check state via the utilities menu item.
await this.page.locator(this.utilitiesMenuButton).click();
const histogramMenuItem = this.page.locator(this.menuHistogramBtn);
await histogramMenuItem.waitFor({ state: 'visible', timeout: 5000 });
const switchEl = this.page.locator(`[data-test="logs-search-bar-menu-histogram-btn"] [data-state="unchecked"]`).first();
await expect(switchEl).toBeVisible({ timeout: 5000 });
const isMenuVisible = await histogramMenuItem.isVisible({ timeout: 2000 }).catch(() => false);
if (isMenuVisible) {
const switchEl = this.page.locator(`[data-test="logs-search-bar-menu-histogram-btn"] [data-state="unchecked"]`).first();
await expect(switchEl).toBeVisible({ timeout: 5000 });
}
await this.page.keyboard.press('Escape').catch(() => {});
}
@@ -2008,13 +2035,29 @@ export class LogsPage {
try {
await this.waitForEditorValue(field);
if (await editorHasField()) return;
// Field still not in editor — the first click likely didn't register.
// Re-clicking is safe because we've verified the field is NOT present
// (so the toggle couldn't have been turned on by the first click).
await this.clickInterestingFieldButton(field);
// Field not in editor. Build a SELECT query that explicitly includes the field
// and write it directly — regardless of whether SQL mode is currently on or off.
// Using clickSQLModeToggle() is unreliable here: when the editor already has a
// SELECT (e.g. from a prior enableSQLMode call), the toggle CLEARS the editor
// instead of adding the field. Direct setQueryEditorContent bypasses that problem.
const appState = await this._mutateSearchObj((searchObj) => ({
fields: [...(searchObj.data?.stream?.interestingFieldList || [])],
stream: searchObj.data?.stream?.selectedStream?.[0] || 'e2e_automate',
}));
const timestamp = '_timestamp';
// Prefer the Vue interestingFieldList; fall back to just [field] so the SELECT
// always contains the field we starred even if Vue state wasn't updated yet.
const fields = appState?.fields?.length ? appState.fields : [field];
const stream = appState?.stream || 'e2e_automate';
const allFields = fields.includes(timestamp) ? fields : [timestamp, ...fields];
const sql = `SELECT ${allFields.join(',')} FROM "${stream}" ORDER BY ${timestamp} DESC`;
await this.setQueryEditorContent(sql);
await this.page.waitForTimeout(300);
if (await editorHasField()) return;
// Final check after waiting for Monaco model stabilisation.
await this.waitForEditorValue(field);
if (!(await editorHasField())) {
throw new Error(`Editor model never contained "${field}" after two click attempts`);
throw new Error(`Editor model never contained "${field}" after writing SELECT query`);
}
} catch (e) {
throw e;
@@ -2030,12 +2073,12 @@ export class LogsPage {
}
async addRemoveInteresting() {
// Click the field button once to toggle it off (remove from query)
// Note: clickInterestingFields() was already called before this, which added the field
// The OField row's tooltip/info-icon overlay (`o-field-row__actions`) intercepts a
// normal click, so force the click past it. AGENT_RULES §4 covers OToast/OInput
// conventions; the underlying field-btn is the legitimate target here.
await this.page.locator(this.interestingFieldBtn('kubernetes_pod_name')).first().click({ force: true });
const field = 'kubernetes_pod_name';
// After clickInterestingFields() a SELECT query is written to the editor, which
// triggers Vue reactivity and may reset the field-search filter. Re-apply the
// filter so the star button is visible, then click it to remove the field.
await this.fillIndexFieldSearchInput(field);
await this.clickInterestingFieldButton(field);
}
// Kubernetes methods
@@ -2520,6 +2563,9 @@ export class LogsPage {
// If a prior auto-search (e.g. from toggling SQL mode) is still running, the button
// renders as "Cancel query" via a v-if/v-else swap — wait for the run-mode variant
// to appear (not in Cancel state) before clicking, so we don't accidentally cancel it.
// Use the full timeout here (not a hard-coded 15 s) so a slow CI auto-search never
// causes us to force-click the Cancel button instead of the Run button.
let buttonWasInCancelState = false;
await this.page.waitForFunction(
(selector) => {
const el = document.querySelector(selector);
@@ -2529,14 +2575,34 @@ export class LogsPage {
return !text.includes('Cancel') && !title.toLowerCase().includes('cancel');
},
this.queryButton,
{ timeout: 15000 }
{ timeout }
).catch(() => {
testLogger.warn('runQueryAndWaitForResults: refresh button never exited Cancel state, continuing');
buttonWasInCancelState = true;
testLogger.warn('runQueryAndWaitForResults: refresh button never exited Cancel state, force-clicking to cancel in-flight search');
});
// Click Run query
// Click Run query (or Cancel, if the prior search is still running)
await btn.click({ force: true });
// If we had to force-click a Cancel button, we cancelled the in-flight search
// instead of starting a new one. Wait for the Cancel to resolve, then click
// the Run button again to actually submit the intended query.
if (buttonWasInCancelState) {
testLogger.warn('runQueryAndWaitForResults: re-clicking Run to submit the intended query after cancel');
await this.page.waitForFunction(
(selector) => {
const el = document.querySelector(selector);
if (!el) return false;
const text = (el.textContent || '').trim();
const isDisabled = el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true';
return !isDisabled && !text.includes('Cancel');
},
this.queryButton,
{ timeout: 15000 }
).catch(() => {});
await btn.click({ force: true });
}
// Wait for the button to enter loading/disabled state (query started)
await this.page.waitForFunction(
(selector) => {
@@ -2550,20 +2616,70 @@ export class LogsPage {
// Query may have completed instantly — that's OK
});
// Wait for the button to exit loading/disabled state (query completed)
// Wait for the button to exit loading/disabled state (query completed).
// The button may stay disabled briefly if patternsState.loading is true (a new
// disabled condition added to the Run Query button in SearchBar.vue). Keep waiting
// until both the search loading AND any patterns loading have settled.
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.hasAttribute('disabled') || btn.getAttribute('aria-busy') === 'true';
const text = btn.textContent?.trim() || '';
return !isDisabled && !isLoading && !text.includes('Cancel');
return !isDisabled && !text.includes('Cancel');
},
this.queryButton,
{ timeout }
);
// Wait for field-schema extraction (loadingStream) to settle so that
// updateGridColumns() has had time to set up resultGrid.columns before
// the caller starts querying for table cells. loadingStream is set to false
// at the end of extractFields() which runs inside processPostPaginationData —
// a floating async promise that may still be in-flight when the Run Query
// button becomes enabled (searchObj.loading = false).
// Polls `searchObj.loadingStream` via the prod-safe `_vnode` walk used by
// `_mutateSearchObj`. Inlined (not a method) because `page.waitForFunction`
// takes a serialised body and we need the polling semantics it provides.
await this.page.waitForFunction(
() => {
try {
const app = document.querySelector('#app');
if (!app?._vnode) return true;
const visited = new Set();
let target = null;
const walk = (node, depth) => {
if (!node || depth > 60 || visited.has(node) || target) return;
visited.add(node);
const ss = node.component?.setupState;
if (ss && 'searchObj' in ss) {
const s = ss.searchObj;
if (s?.meta && 'sqlMode' in s.meta) {
target = s;
return;
}
}
if (node.component?.subTree) walk(node.component.subTree, depth + 1);
if (Array.isArray(node.children)) {
for (const c of node.children) {
if (c && typeof c === 'object') walk(c, depth + 1);
if (target) return;
}
}
};
walk(app._vnode, 0);
if (!target) return true;
return target.loadingStream !== true;
} catch (e) {
return true;
}
},
null,
{ timeout: 15000 }
).catch(() => {
testLogger.warn('runQueryAndWaitForResults: loadingStream check timed out, proceeding');
});
testLogger.info('Query execution complete - Run query button ready');
}
@@ -2646,57 +2762,91 @@ export class LogsPage {
async waitForSearchBarRefreshButton() {
const btn = this.page.locator(this.searchBarRefreshButton).first();
await btn.waitFor({ state: 'visible', timeout: 15000 });
// OButton renders <button disabled> while loading. Wait for the button to be enabled
// before clicking — OButton.handleClick() guards on loading and won't emit if disabled.
// Increase to 30 s — the button may be absent from the DOM if
// patternsState.loading is true (patterns cancel button takes its place via v-if).
await btn.waitFor({ state: 'visible', timeout: 30000 });
// OButton renders <button disabled> while loading or when patternsState.loading
// is true (a new disabled condition added to the Run Query button in SearchBar.vue).
// Wait for the button to be enabled before clicking.
await this.page.waitForFunction(
(selector) => {
const el = document.querySelector(selector);
return el != null && !el.hasAttribute('disabled');
},
this.searchBarRefreshButton,
{ timeout: 15000 }
{ timeout: 30000 }
);
}
/**
* Open the utilities ("More") dropdown so the SQL mode toggle becomes visible.
* Returns true if the dropdown was opened by this call; false if toggle was already visible.
* Always pair with _closeUtilitiesMenuAfterSqlToggle() when the caller opened the menu.
* Wait until the log result table has at least one visible row at index 0.
* Useful as a data-readiness gate before asserting on specific cell content.
* Checks for any column in row 0 (not just "source") to be agnostic of column layout.
* @param {number} timeout - Max wait time in ms (default 15000)
* @returns {Promise<boolean>} true if a row appeared, false if timed out
*/
async waitForTableHits(timeout = 15000) {
try {
await this.page.waitForFunction(
() => document.querySelector('[data-test^="log-table-column-0-"]') !== null,
{ timeout },
);
return true;
} catch {
return false;
}
}
/**
* Open the utilities ("More") dropdown so the SQL mode menu item becomes visible.
* Uses [data-test="logs-search-bar-menu-sql-mode-btn"] (the ODropdownItem) — NOT the
* syntax-guide which incorrectly carries the legacy data-test.
* Returns true if the dropdown was opened; false if it was already open.
* SQL mode toggle was removed from the UI. These helpers are kept as no-ops so
* any residual callers don't throw hard failures.
*/
async _openUtilitiesMenuForSqlMode() {
const sqlModeMenuItem = this.page.locator(this.menuSqlModeBtn);
const isVisible = await sqlModeMenuItem.isVisible({ timeout: 500 }).catch(() => false);
if (!isVisible) {
await this.page.locator(this.utilitiesMenuButton).click();
await sqlModeMenuItem.waitFor({ state: 'visible', timeout: 5000 });
return true;
}
testLogger.info('_openUtilitiesMenuForSqlMode: SQL mode toggle removed from UI — no-op');
return false;
}
/** Press Escape to close the utilities dropdown after a SQL mode check/toggle. */
async _closeUtilitiesMenuAfterSqlToggle() {
await this.page.keyboard.press('Escape');
await this.page.locator(this.menuSqlModeBtn).waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {});
// no-op — SQL mode toggle removed from UI
}
async clickSQLModeToggle() {
// SQL mode lives in the utilities ("More") dropdown.
// Click menuSqlModeBtn (ODropdownItem) — @select.prevent toggles sqlMode without closing.
const menuOpened = await this._openUtilitiesMenuForSqlMode();
const sqlModeBtn = this.page.locator(this.menuSqlModeBtn);
await expect(sqlModeBtn).toBeVisible({ timeout: 5000 });
await sqlModeBtn.click({ force: true });
if (menuOpened) {
await this._closeUtilitiesMenuAfterSqlToggle();
// Behaves as a true toggle:
// SQL ON → clear the editor (FTS mode, no SELECT)
// SQL OFF → write SELECT query with current interesting fields (SQL mode)
//
// Uses setQueryEditorContent (Monaco executeEdits API) rather than keyboard input —
// keyboard typing is focus-sensitive and silently fails in headless CI when focus
// lands on another element.
const isSQL = await this.isSqlModeEnabled();
if (isSQL) {
// Toggle OFF: clear editor so auto-detection sets sqlMode = false
await this.setQueryEditorContent('');
await this.page.waitForTimeout(300);
testLogger.info('clickSQLModeToggle: toggled OFF (editor cleared)');
return;
}
// Toggle ON: read interesting fields + stream from Vue state, build SELECT
const appState = await this._mutateSearchObj((searchObj) => ({
fields: [...(searchObj.data?.stream?.interestingFieldList || [])],
stream: searchObj.data?.stream?.selectedStream?.[0] || 'e2e_automate',
}));
const timestamp = '_timestamp';
const fields = appState?.fields || [];
const stream = appState?.stream || 'e2e_automate';
const allFields = fields.includes(timestamp) ? fields : [timestamp, ...fields];
// Always build an explicit field list — never fall back to SELECT * when we
// have at least _timestamp, so assertions like "includes(_timestamp,field)"
// work even when interestingFieldList had only one user field.
const sql = allFields.length > 0
? `SELECT ${allFields.join(',')} FROM "${stream}" ORDER BY ${timestamp} DESC`
: `SELECT * FROM "${stream}"`;
await this.setQueryEditorContent(sql);
await this.page.waitForTimeout(300);
testLogger.info(`clickSQLModeToggle: toggled ON — set SQL="${sql.substring(0, 80)}"`);
}
async clickShowQueryToggle() {
@@ -4051,8 +4201,47 @@ export class LogsPage {
await btnLocator.click({ force: true });
}
/**
* Idempotent version of clickInterestingFieldButton: only clicks if the field is
* NOT already marked as interesting. Use this when the goal is to guarantee the
* field is in interestingFieldList, not to toggle it. If the button's title
* already says "Remove from interesting fields" the field is already starred and
* we skip the click so we don't accidentally remove it.
*/
async ensureFieldIsInteresting(field) {
const btnLocator = this.page.locator(this.interestingFieldBtn(field)).first();
const inputLocator = this.page.locator(this.logSearchIndexListFieldSearchInput);
for (let attempt = 0; attempt < 5; attempt++) {
const visible = await btnLocator.waitFor({ state: 'visible', timeout: 8000 })
.then(() => true).catch(() => false);
if (visible) {
const title = await btnLocator.getAttribute('title').catch(() => '');
if (title && title.toLowerCase().includes('remove')) {
// Already interesting — nothing to do.
return;
}
await btnLocator.click({ force: true });
return;
}
await inputLocator.fill('');
await inputLocator.click({ force: true });
await inputLocator.pressSequentially(field, { delay: 30 });
await expect(inputLocator).toHaveValue(field, { timeout: 5000 }).catch(() => {});
}
await btnLocator.waitFor({ state: 'visible', timeout: 8000 });
const title = await btnLocator.getAttribute('title').catch(() => '');
if (title && title.toLowerCase().includes('remove')) {
return;
}
await btnLocator.click({ force: true });
}
async expectInterestingFieldInEditor(field) {
return await expect(this.page.locator(this.queryEditor).getByText(new RegExp(field)).first()).toBeVisible();
// Monaco tokenizes text across multiple <span> elements, so DOM-based getByText
// with a regex can miss substrings that span token boundaries. Read the Monaco
// model value directly instead — this is authoritative regardless of rendering.
await this.expectQueryEditorContainsText(field);
}
async expectInterestingFieldInTable(field) {
@@ -4064,8 +4253,13 @@ export class LogsPage {
}
async expectQueryEditorContainsText(text) {
// Wait for Monaco editor to be available
// Wait for Monaco editor container to be visible
await this.page.locator(this.queryEditor).waitFor({ state: 'visible', timeout: 15000 });
// Wait for Monaco's .inputarea — only present after the editor is fully
// initialised (setupEditor() async load completes). This prevents the
// waitForFunction below from timing out on page reloads where window.monaco
// is not yet set when domcontentloaded fires.
await this.page.locator(this.queryEditor).locator('.inputarea').waitFor({ state: 'visible', timeout: 20000 });
// Assert against Monaco's model value (not DOM view-lines which render
// line numbers when empty — AGENT_RULES §5).
await this.page.waitForFunction(
@@ -4362,7 +4556,7 @@ export class LogsPage {
}
async expectQueryEditorContainsSelectFrom() {
return await expect(this.page.locator(this.queryEditor)).toContainText('SELECT * FROM "e2e_automate"');
return await expect(this.page.locator(this.queryEditor)).toContainText('FROM "e2e_automate"');
}
generateRandomString() {
@@ -4748,10 +4942,10 @@ export class LogsPage {
}
// Quick Mode is off — open the utilities dropdown and enable the OSwitch.
// Post-migration: the trigger is an ODropdownItem (data-test="logs-search-bar-quick-mode-toggle-btn")
// wrapping an OSwitch (data-test="logs-search-bar-quick-mode-switch"). The inner button carries
// data-test="logs-search-bar-quick-mode-switch-btn" and data-state="checked|unchecked".
const quickModeItem = this.page.locator('[data-test="logs-search-bar-quick-mode-toggle-btn"]');
// Post-UX-revamp: the ODropdownItem was renamed to logs-search-bar-menu-quick-mode-toggle-btn.
// The OSwitch inside carries data-test="logs-search-bar-quick-mode-switch" and its
// inner button data-test="logs-search-bar-quick-mode-switch-btn" with data-state="checked|unchecked".
const quickModeItem = this.page.locator('[data-test="logs-search-bar-menu-quick-mode-toggle-btn"]');
const quickModeSwitch = this.page.locator('[data-test="logs-search-bar-quick-mode-switch"]');
let enabled = false;
for (let attempt = 0; attempt < 3; attempt++) {
@@ -4916,29 +5110,21 @@ export class LogsPage {
// New POM methods for PR tests
async executeBlankQueryWithKeyboardShortcut() {
// Click the Monaco editor wrapper, then wait for the inner inputarea (Monaco's
// hidden textarea) to be visible so keyboard events land in the editor.
await this.page.locator(this.queryEditor).click();
const inputarea = this.page.locator(this.queryEditor).locator('.inputarea');
await inputarea.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
// Select all and delete — this clears Monaco's content and triggers its
// onChange, which debounces the update to searchObj.data.query (100 ms).
await this.page.locator(this.queryEditor).press(
process.platform === "darwin" ? "Meta+A" : "Control+A"
);
await this.page.keyboard.press("Backspace");
// Wait for the 100 ms debounce to propagate searchObj.data.query = "".
// Without this pause, Ctrl+Enter fires before the store update, so the old
// SELECT query is used and the run succeeds (no error banner).
// A truly blank SQL query is not achievable: the fullSQLMode watcher in Index.vue
// calls setQuery() whenever sqlMode transitions to true with an empty editor, which
// auto-generates "SELECT * FROM <stream>" — so Ctrl+Enter would run a valid query
// and return results instead of an error.
//
// Use an intentionally invalid SQL statement instead. setQuery() returns early when
// the current query already contains "select" (Index.vue line ~1397), so the watcher
// will not overwrite it. The backend receives malformed SQL and returns an error,
// which sets searchObj.data.errorMsg and shows the error banner.
await this.setQueryEditorContent('SELECT *');
await this.page.waitForTimeout(300);
// Run the blank query — Monaco's internal Ctrl+Enter keybinding emits
// "run-query" which propagates to SearchBar's handleRunQueryFn.
await this.page.locator(this.queryEditor).press(
process.platform === "darwin" ? "Meta+Enter" : "Control+Enter"
);
await this.clickQueryEditor();
await this.page.waitForTimeout(100);
await this.page.keyboard.press(process.platform === "darwin" ? "Meta+Enter" : "Control+Enter");
}
async expectBlankQueryError() {
@@ -5279,23 +5465,13 @@ export class LogsPage {
}
async ensureHistogramToggleState(desiredState) {
// Histogram is now inside the utilities ("More") menu.
await this.page.keyboard.press('Escape').catch(() => {});
await this.page.locator(this.utilitiesMenuButton).click();
const histogramMenuItem = this.page.locator(this.menuHistogramBtn);
await histogramMenuItem.waitFor({ state: 'visible', timeout: 5000 });
const switchEl = this.page.locator(`[data-test="logs-search-bar-menu-histogram-btn"] [data-state]`).first();
await switchEl.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
const state = await switchEl.getAttribute('data-state').catch(() => null);
const isOn = state === 'checked';
// In normal viewports the histogram is an inline toolbar button.
// Delegates to logsQueryPage which already handles both inline and menu cases.
const isOn = await this.logsQueryPage.isHistogramOn();
if (desiredState !== isOn) {
// force: true — ODropdown portal transiently detaches items during initial render
await histogramMenuItem.click({ force: true });
await this.page.keyboard.press('Escape').catch(() => {});
await this.toggleHistogram(); // toggleHistogram checks inline first
return true; // State was changed
}
await this.page.keyboard.press('Escape').catch(() => {});
return false; // State was already correct
}
@@ -6476,16 +6652,15 @@ export class LogsPage {
}
/**
* Wait for SQL mode to be active after switching
* Wait for SQL mode to be active after switching.
* SQL mode is auto-detected from query content — poll until the editor contains SELECT...FROM.
*/
async waitForSQLModeActive() {
// SQL mode switch is inside the utilities menu. Use getSQLModeState() poll to confirm
// the OSwitch inner button reaches data-state="checked" without leaving the menu open.
await expect.poll(
() => this.getSQLModeState(),
() => this.isSqlModeEnabled(),
{ timeout: 10000 }
).toBe('checked');
testLogger.info('SQL mode switch stabilized');
).toBe(true);
testLogger.info('SQL mode active (editor contains SELECT...FROM)');
}
/**
@@ -6809,39 +6984,131 @@ export class LogsPage {
}
/**
* Check if SQL mode is currently enabled.
* Opens the utilities dropdown, reads the OSwitch inner button's data-state, then closes it.
* @returns {Promise<boolean>} True if SQL mode is enabled
* SQL mode is auto-detected from query content: ON when the editor has SELECT...FROM.
* @returns {Promise<boolean>} True if the current query looks like SQL
*/
async isSqlModeEnabled() {
const menuOpened = await this._openUtilitiesMenuForSqlMode();
const stateEl = this.page.locator(this.menuSqlModeBtnState).first();
await stateEl.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
const state = await stateEl.getAttribute('data-state').catch(() => null);
if (menuOpened) await this._closeUtilitiesMenuAfterSqlToggle();
return state === 'checked';
const text = await this.getQueryEditorText();
if (!text) return false;
const lower = text.toLowerCase().trim();
return lower.includes('select') && lower.includes('from');
}
/**
* Enable SQL mode if currently disabled; no-op if already enabled.
* Opens the utilities menu to access the toggle, then closes it.
* Read the currently selected stream name from Vue reactive state.
* Falls back to 'e2e_automate' if the state is unavailable.
* @returns {Promise<string>}
*/
async _getSelectedStream() {
const stream = await this._mutateSearchObj(
(searchObj) => searchObj.data?.stream?.selectedStream?.[0] || null
);
return stream || 'e2e_automate';
}
/**
* Enable SQL mode if currently disabled.
* Uses a direct path: try Vue state mutation first, then fall back to setting
* a SELECT query in the editor. Avoids the strict expect.poll assertion in
* setQueryEditorContent (which can fail if the fullSQLMode watcher fires
* asynchronously and resets the editor after executeEdits).
*/
async enableSqlModeIfDisabled() {
const menuOpened = await this._openUtilitiesMenuForSqlMode();
const stateEl = this.page.locator(this.menuSqlModeBtnState).first();
await stateEl.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
const state = await stateEl.getAttribute('data-state').catch(() => null);
if (state !== 'checked') {
await this.page.locator(this.menuSqlModeBtn).click();
await expect.poll(async () => {
const s = await stateEl.getAttribute('data-state').catch(() => null);
return s === 'checked';
}, { timeout: 5000 }).toBe(true);
testLogger.info('SQL mode enabled');
} else {
testLogger.info('SQL mode already enabled');
const isSQL = await this.isSqlModeEnabled();
if (isSQL) {
testLogger.info('enableSqlModeIfDisabled: SQL mode already on — skipping');
return;
}
if (menuOpened) await this._closeUtilitiesMenuAfterSqlToggle();
const stream = await this._getSelectedStream();
const set = await this._setSqlModeViaVue(true);
if (!set) {
// Vue state inaccessible — write SELECT directly to trigger auto-detection
await this.setQueryEditorValue(`SELECT * FROM "${stream}"`);
}
await this.page.waitForTimeout(600);
// If the Vue watcher didn't auto-update the editor, force it now
if (!(await this.isSqlModeEnabled())) {
await this.setQueryEditorValue(`SELECT * FROM "${stream}"`);
await this.page.waitForTimeout(400);
}
testLogger.info('enableSqlModeIfDisabled: SQL mode enabled');
}
/**
* Set searchObj.meta.sqlMode via the Vue component state accessed from the browser context.
* Tries multiple anchor elements (toolbar buttons rendered directly in SearchBar, the query
* editor wrapper) and walks both the DOM parent chain and the Vue component parent chain to
* reliably reach SearchBar even when the query editor is wrapped in an extra component layer
* (UnifiedQueryEditor / QueryEditor.vue).
* @param {boolean} value - true to enable SQL mode, false to disable
* @returns {Promise<boolean>} true if the state was set successfully
*/
async _setSqlModeViaVue(value) {
// Delegates to _mutateSearchObj — single source of truth for the prod-safe
// `_vnode` walk. See _mutateSearchObj jsdoc for why __vueParentComponent
// can't be used in CI.
const result = await this._mutateSearchObj((searchObj, v) => {
searchObj.meta.sqlMode = v;
return true;
}, value);
return result === true;
}
/**
* Read `searchObj` via the prod-safe `_vnode` walk and apply `mutate(searchObj)`
* inside `page.evaluate`. Returns whatever the mutate fn returns (defaults to true
* on success). Use this instead of `__vueParentComponent` walks anywhere that
* needs to read or write Vue reactive state — `__vueParentComponent` is dev-only
* and undefined in the production frontend bundle CI ships.
*/
async _mutateSearchObj(mutate, payload) {
return await this.page.evaluate(({ mutateSrc, payload }) => {
try {
const app = document.querySelector('#app');
if (!app?._vnode) return null;
const visited = new Set();
let target = null;
const walk = (node, depth) => {
if (!node || depth > 60 || visited.has(node) || target) return;
visited.add(node);
const setupState = node.component?.setupState;
if (setupState && 'searchObj' in setupState) {
const s = setupState.searchObj;
if (s?.meta && 'sqlMode' in s.meta) {
target = s;
return;
}
}
if (node.component?.subTree) walk(node.component.subTree, depth + 1);
if (Array.isArray(node.children)) {
for (const c of node.children) {
if (c && typeof c === 'object') walk(c, depth + 1);
if (target) return;
}
}
};
walk(app._vnode, 0);
if (!target) return null;
// eslint-disable-next-line no-new-func
const fn = new Function('searchObj', 'payload', `return (${mutateSrc})(searchObj, payload)`);
return fn(target, payload);
} catch (e) {
return null;
}
}, { mutateSrc: mutate.toString(), payload });
}
/**
* Set `searchObj.data.query` and `searchObj.data.editorValue` to `query` via
* the prod-safe `_vnode` walk. Returns true on success, null if Vue state
* could not be located.
*/
async _setSearchObjQuery(query) {
return await this._mutateSearchObj((searchObj, q) => {
searchObj.data.query = q;
searchObj.data.editorValue = q;
return true;
}, query);
}
/**
@@ -7206,23 +7473,19 @@ export class LogsPage {
* Opens the utilities dropdown first since the toggle lives inside it.
*/
async clickSQLModeSwitch() {
const menuOpened = await this._openUtilitiesMenuForSqlMode();
await this.page.locator(this.menuSqlModeBtn).click();
if (menuOpened) await this._closeUtilitiesMenuAfterSqlToggle();
// SQL mode toggle removed — delegate to clickSQLModeToggle which writes a SELECT
// query into the editor so that SQL mode is auto-detected as ON.
await this.clickSQLModeToggle();
}
/**
* Get SQL mode data-state value ("checked" | "unchecked").
* Opens the utilities dropdown to read the OSwitch inner button state, then closes it.
* Get SQL mode data-state value.
* SQL mode toggle was removed from the UI — always returns null.
* @returns {Promise<string|null>}
*/
async getSQLModeState() {
const menuOpened = await this._openUtilitiesMenuForSqlMode();
const stateEl = this.page.locator(this.menuSqlModeBtnState).first();
await stateEl.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
const state = await stateEl.getAttribute('data-state').catch(() => null);
if (menuOpened) await this._closeUtilitiesMenuAfterSqlToggle();
return state;
testLogger.info('getSQLModeState: SQL mode toggle removed from UI — returning null');
return null;
}
/**
@@ -7344,16 +7607,24 @@ export class LogsPage {
/**
* Toggle histogram on/off.
* In normal viewports the histogram is a standalone inline toolbar button.
* Only falls back to the utilities menu in narrow viewports (shouldMoveButtonsToMenu).
*/
async toggleHistogram() {
const menuItem = this.page.locator('[data-test="logs-search-bar-menu-histogram-btn"]');
const isMenuItemVisible = await menuItem.isVisible({ timeout: 500 }).catch(() => false);
if (!isMenuItemVisible) {
await this.page.locator(this.utilitiesMenuButton).click();
await menuItem.waitFor({ state: 'visible', timeout: 5000 });
await this.page.keyboard.press('Escape').catch(() => {});
const inlineBtn = this.page.locator('[data-test="logs-search-bar-histogram-btn"]');
const isInline = await inlineBtn.isVisible({ timeout: 2000 }).catch(() => false);
if (isInline) {
await inlineBtn.click();
testLogger.info('Histogram toggled (inline button)');
return;
}
// Narrow-viewport fallback: open the utilities menu and click the menu item.
await this.page.locator(this.utilitiesMenuButton).click();
const menuItem = this.page.locator('[data-test="logs-search-bar-menu-histogram-btn"]');
await menuItem.waitFor({ state: 'visible', timeout: 5000 });
await menuItem.click();
testLogger.info('Histogram toggled');
testLogger.info('Histogram toggled (menu item)');
}
/**
@@ -7699,6 +7970,12 @@ export class LogsPage {
target.executeEdits('setQueryEditorContent', [
{ range: fullRange, text: value, forceMoveMarkers: true },
]);
// executeEdits is a no-op on read-only editors (e.g. build-tab auto mode).
// Fall back to model.setValue() which bypasses the readOnly restriction and
// still fires onDidChangeModelContent so the Vue component can react.
if (model.getValue() !== value) {
model.setValue(value);
}
target.setSelection(model.getFullModelRange());
}, { selector: this.queryEditor, value: query });
// Verify via getValue() that the model now reflects the value (handles empty string too)
@@ -7710,55 +7987,102 @@ export class LogsPage {
/**
* Enable SQL mode if not already enabled.
* Opens the utilities ("More") dropdown, reads + toggles the OSwitch, then closes it.
* Sets sqlMode=true via the Vue component state (triggers the sqlMode watcher in build
* tab or enables auto-detection on the logs tab). Falls back to typing SELECT * if Vue
* state access is unavailable.
*/
async enableSqlModeIfNeeded() {
const menuOpened = await this._openUtilitiesMenuForSqlMode();
const stateEl = this.page.locator(this.menuSqlModeBtnState).first();
await stateEl.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
const isOn = (await stateEl.getAttribute('data-state').catch(() => null)) === 'checked';
if (!isOn) {
// force: true bypasses actionability checks — ODropdown portal transiently detaches items on render
await this.page.locator(this.menuSqlModeBtn).click({ force: true });
// In CI the dropdown may close after the click, making stateEl detached/hidden.
// Re-open the menu on each poll iteration so the state element is always visible.
await expect.poll(async () => {
await this._openUtilitiesMenuForSqlMode();
const s = await this.page.locator(this.menuSqlModeBtnState).first()
.getAttribute('data-state').catch(() => null);
return s === 'checked';
}, { timeout: 15000 }).toBe(true);
testLogger.info('SQL mode enabled');
} else {
testLogger.info('SQL mode already enabled');
const isSQL = await this.isSqlModeEnabled();
if (isSQL) {
testLogger.info('enableSqlModeIfNeeded: SQL mode already on — skipping');
return;
}
if (menuOpened) await this._closeUtilitiesMenuAfterSqlToggle();
// Set the Vue flag first — in the build tab this triggers the sqlMode watcher
// which updates the editor with the full generated SQL query.
await this._setSqlModeViaVue(true);
// Poll up to 2000ms for the Vue watcher chain to update the Monaco editor.
// The watcher is async (reads buildDashboardPanelData, awaits onBuildQueryGenerated,
// then Vue re-renders the editor prop) — a fixed 600ms was too short under CI load.
await this.page.waitForFunction((selector) => {
const host = document.querySelector(selector);
if (!host) return false;
const editors = window.monaco?.editor?.getEditors?.() ?? [];
for (const ed of editors) {
const node = ed.getDomNode?.();
if (node && host.contains(node)) {
const val = (ed.getValue() || '').toLowerCase().trim();
return val.includes('select') && val.includes('from');
}
}
return false;
}, this.queryEditor, { timeout: 2000, polling: 100 }).catch(() => {});
// Check if the editor was updated (build tab watcher fired).
// In the main logs tab the watcher returns early, so the editor stays in FTS mode.
// In that case fall back to writing SELECT directly so the editor reflects SQL mode.
// In build mode the query editor is read-only (buildModeQueryEditorDisabled=true),
// so Monaco executeEdits is blocked by the readOnly flag. Instead, update
// searchObj.data.query directly via Vue state — use the same multi-anchor + comp.parent
// traversal as _setSqlModeViaVue to reliably reach the component hosting searchObj.
const isSQLNow = await this.isSqlModeEnabled();
if (!isSQLNow) {
const stream = await this._getSelectedStream();
const sql = `SELECT * FROM "${stream}"`;
const updatedViaState = await this._setSearchObjQuery(sql);
if (!updatedViaState) {
// Last resort: write SELECT directly via setQueryEditorContent.
// In build-tab auto mode the editor is read-only, but setQueryEditorContent
// now falls back to model.setValue() which bypasses the readOnly restriction.
await this.setQueryEditorContent(sql);
}
// Allow Vue reactivity and CodeQueryEditor props.query watcher to propagate
await this.page.waitForTimeout(500);
}
testLogger.info('enableSqlModeIfNeeded: SQL mode enabled');
}
/**
* Disable SQL mode if currently enabled.
* Opens the utilities ("More") dropdown, reads + toggles the OSwitch, then closes it.
* Sets sqlMode=false via the Vue component state (triggers the sqlMode watcher in build
* tab to show WHERE clause). Falls back to direct state update if the editor is read-only
* (build tab auto mode) or the async watcher chain is too slow.
*/
async disableSqlModeIfNeeded() {
const menuOpened = await this._openUtilitiesMenuForSqlMode();
const stateEl = this.page.locator(this.menuSqlModeBtnState).first();
await stateEl.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
const isOn = (await stateEl.getAttribute('data-state').catch(() => null)) === 'checked';
if (isOn) {
await this.page.locator(this.menuSqlModeBtn).click({ force: true });
// In CI the dropdown may close after the click, making stateEl detached/hidden.
// Re-open the menu on each poll iteration so the state element is always visible.
await expect.poll(async () => {
await this._openUtilitiesMenuForSqlMode();
const s = await this.page.locator(this.menuSqlModeBtnState).first()
.getAttribute('data-state').catch(() => null);
return s === 'unchecked';
}, { timeout: 15000 }).toBe(true);
testLogger.info('SQL mode disabled');
} else {
testLogger.info('SQL mode already disabled');
const isSQL = await this.isSqlModeEnabled();
if (!isSQL) {
testLogger.info('disableSqlModeIfNeeded: SQL mode already off — skipping');
return;
}
if (menuOpened) await this._closeUtilitiesMenuAfterSqlToggle();
// Set sqlMode=false via Vue state — in build mode this triggers the Index.vue
// watcher which calls onBuildQueryGenerated() → extractWhereClause() (async) →
// searchObj.data.query = whereClause → QueryEditor prop watcher → Monaco setValue.
await this._setSqlModeViaVue(false);
await this.page.waitForTimeout(600);
// Verify the editor was updated. The Vue watcher chain is async and involves an
// SQL-parser dynamic import, so it can exceed 600ms on first load or under CI load.
// If the editor still contains SELECT, fall back to the same pattern as
// enableSqlModeIfNeeded: set searchObj.data.query directly via Vue state so the
// QueryEditor prop watcher calls editorObj.getModel().setValue() which bypasses
// the Monaco readOnly flag (unlike keyboard shortcuts which do not).
const isStillSQL = await this.isSqlModeEnabled();
if (isStillSQL) {
testLogger.warn('disableSqlModeIfNeeded: Vue watcher did not update editor in time — using direct state fallback');
const updatedViaState = await this._mutateSearchObj((searchObj) => {
searchObj.data.query = '';
searchObj.data.editorValue = '';
return true;
});
if (!updatedViaState) {
// Absolute fallback: programmatic Monaco clear via executeEdits which
// bypasses the readOnly flag (unlike keyboard Ctrl+A + Backspace which fails
// silently on read-only editors in the build tab's auto mode).
await this.setQueryEditorContent('').catch(() => {});
}
await this.page.waitForTimeout(500);
}
testLogger.info('disableSqlModeIfNeeded: SQL mode disabled');
}
/**
@@ -9272,14 +9596,12 @@ export class LogsPage {
async findQueryModeToggle() {
const selectors = [
this.quickModeToggle,
this.sqlModeToggle,
'[data-test="logs-search-bar-quick-mode-toggle-btn"]',
'[data-test="logs-search-bar-sql-mode-toggle-btn"]',
// SQL mode toggle was removed from the UI
'[data-test="logs-search-bar-menu-quick-mode-toggle-btn"]',
'[data-test="logs-search-bar-ui-mode-btn"]',
'[data-test="logs-search-ui-mode-btn"]',
'button:has-text("UI Mode")',
'[data-test*="quick-mode"]',
'[data-test*="sql-mode"]',
];
for (const sel of selectors) {
@@ -83,8 +83,16 @@ export class LogsQueryPage {
}
async toggleHistogram() {
// Histogram toggle is inside the utilities ("More") menu.
// In normal viewport the histogram is a standalone toolbar button (inline).
// In narrow viewport it moves into the More menu.
await this.page.keyboard.press('Escape').catch(() => {});
const inlineBtn = this.page.locator('[data-test="logs-search-bar-histogram-btn"]');
const isInline = await inlineBtn.isVisible({ timeout: 2000 }).catch(() => false);
if (isInline) {
await inlineBtn.click();
return;
}
// Narrow-viewport fallback: open utilities menu and click the menu item.
await this.page.locator(this.utilitiesMenuButton).click();
const histogramMenuItem = this.page.locator('[data-test="logs-search-bar-menu-histogram-btn"]');
await histogramMenuItem.waitFor({ state: 'visible', timeout: 5000 });
@@ -92,8 +100,16 @@ export class LogsQueryPage {
}
async isHistogramOn() {
// Histogram is inside the utilities ("More") menu — open menu, read OSwitch state, close menu.
// In normal viewport the histogram is a standalone inline toolbar button.
// Check its OSwitch state directly without opening the More menu.
await this.page.keyboard.press('Escape').catch(() => {});
const inlineBtn = this.page.locator('[data-test="logs-search-bar-histogram-btn"]');
const isInline = await inlineBtn.isVisible({ timeout: 2000 }).catch(() => false);
if (isInline) {
const switchChecked = inlineBtn.locator('[data-state="checked"]');
return (await switchChecked.count()) > 0;
}
// Narrow-viewport fallback: check state via the More menu item.
await this.page.locator(this.utilitiesMenuButton).click();
const histogramMenuItem = this.page.locator('[data-test="logs-search-bar-menu-histogram-btn"]');
await histogramMenuItem.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
@@ -110,65 +126,40 @@ export class LogsQueryPage {
}
}
/** Open the utilities dropdown so the SQL mode menu item becomes visible. Returns true if opened. */
/** SQL mode toggle was removed from the UI — no-op, always returns false. */
async _openUtilitiesMenuForSqlMode() {
const sqlModeMenuItem = this.page.locator('[data-test="logs-search-bar-menu-sql-mode-btn"]');
const isVisible = await sqlModeMenuItem.isVisible({ timeout: 500 }).catch(() => false);
if (!isVisible) {
await this.page.locator(this.utilitiesMenuButton).click();
await sqlModeMenuItem.waitFor({ state: 'visible', timeout: 5000 });
return true;
}
// SQL mode is now auto-detected from query content (SELECT...FROM = SQL ON, else OFF).
// There is no longer a dedicated SQL mode toggle button in the utilities menu.
return false;
}
async isSQLModeOn() {
// Open the dropdown to read the OSwitch inner button's data-state, then close it.
const menuOpened = await this._openUtilitiesMenuForSqlMode();
const stateEl = this.page.locator('[data-test="logs-search-bar-menu-sql-mode-btn"] [data-state]').first();
await stateEl.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
const state = await stateEl.getAttribute('data-state').catch(() => null);
if (menuOpened) {
await this.page.keyboard.press('Escape');
await this.page.locator('[data-test="logs-search-bar-menu-sql-mode-btn"]').waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {});
}
return state === 'checked';
// SQL mode is auto-detected: if the Monaco editor contains SELECT...FROM, SQL mode is ON.
const text = await this.page.evaluate((selector) => {
const host = document.querySelector(selector);
if (!host) return null;
const editors = window.monaco?.editor?.getEditors?.() ?? [];
for (const ed of editors) {
const node = ed.getDomNode?.();
if (node && host.contains(node)) return ed.getValue();
}
return null;
}, '[data-test="logs-search-bar-query-editor"]');
if (!text) return false;
const lower = text.toLowerCase().trim();
return lower.includes('select') && lower.includes('from');
}
async ensureSQLMode() {
const menuOpened = await this._openUtilitiesMenuForSqlMode();
const stateEl = this.page.locator('[data-test="logs-search-bar-menu-sql-mode-btn"] [data-state]').first();
await stateEl.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
const isOn = (await stateEl.getAttribute('data-state').catch(() => null)) === 'checked';
if (!isOn) {
await this.page.locator('[data-test="logs-search-bar-menu-sql-mode-btn"]').click();
await expect.poll(async () => {
const s = await stateEl.getAttribute('data-state').catch(() => null);
return s === 'checked';
}, { timeout: 5000 }).toBe(true);
}
if (menuOpened) {
await this.page.keyboard.press('Escape');
await this.page.locator('[data-test="logs-search-bar-menu-sql-mode-btn"]').waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {});
}
// SQL mode toggle removed from UI. SQL mode is auto-detected from SELECT...FROM in query.
// Callers that need SQL mode should set a SELECT query via the editor after this call.
testLogger.info('ensureSQLMode: SQL mode toggle removed — SQL mode is auto-detected from query content');
}
async ensureFTSMode() {
const menuOpened = await this._openUtilitiesMenuForSqlMode();
const stateEl = this.page.locator('[data-test="logs-search-bar-menu-sql-mode-btn"] [data-state]').first();
await stateEl.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {});
const isOn = (await stateEl.getAttribute('data-state').catch(() => null)) === 'checked';
if (isOn) {
await this.page.locator('[data-test="logs-search-bar-menu-sql-mode-btn"]').click();
await expect.poll(async () => {
const s = await stateEl.getAttribute('data-state').catch(() => null);
return s === 'unchecked';
}, { timeout: 5000 }).toBe(true);
}
if (menuOpened) {
await this.page.keyboard.press('Escape');
await this.page.locator('[data-test="logs-search-bar-menu-sql-mode-btn"]').waitFor({ state: 'hidden', timeout: 3000 }).catch(() => {});
}
// SQL mode toggle removed from UI. SQL mode is auto-detected from SELECT...FROM in query.
// Callers that need FTS mode should set a non-SELECT query via the editor after this call.
testLogger.info('ensureFTSMode: SQL mode toggle removed — SQL mode is auto-detected from query content');
}
async _isAutoQueryEnabled() {
+68 -10
View File
@@ -121,20 +121,78 @@ class UnflattenedPage {
}
/**
* Toggle SQL mode via the utilities ("More") menu.
* Wait for an interesting-field toggle button's `title` to flip to
* "Remove from interesting fields" — the DOM-side proxy for
* `field.isInterestingField === true`. Used instead of reading Vue state
* because `__vueParentComponent` is dev-only in Vue 3 and undefined in
* production builds.
*/
async expectFieldMarkedAsInteresting(fieldName, opts = {}) {
const { timeout = 10000 } = opts;
const btn = this.page.locator(
`[data-test="logs-search-index-list"] [data-test="log-search-index-list-interesting-${fieldName}-field-btn"]`
).first();
await expect(btn).toHaveAttribute('title', /remove from interesting fields/i, { timeout });
}
/**
* Switch to SQL mode.
*
* Post-menu-migration: the SQL mode switch was moved from the main search bar
* into the utilities dropdown, so the switch isn't in the DOM until the menu
* is opened. Open the menu, then click the inner switch button (auto-generated
* `-btn` suffix on the OSwitch parent data-test in SearchBar.vue).
* The SQL mode toggle button was removed from the UI — SQL mode is now
* auto-detected from query content (SELECT...FROM = SQL ON).
* This method reads the current interesting fields and selected stream from
* Vue component state, then writes a SELECT query into the Monaco editor so
* that the interesting fields appear in the editor (matching previous behavior).
*/
async toggleSqlMode() {
const isAlreadyVisible = await this.sqlModeToggle.isVisible({ timeout: 500 }).catch(() => false);
if (!isAlreadyVisible) {
await this.utilitiesMenuButton.click();
await this.sqlModeToggle.waitFor({ state: 'visible', timeout: 5000 });
// Read the set of "interesting" fields from the DOM instead of walking
// `__vueParentComponent`, which is dev-only and undefined in production
// builds (the build CI ships). Each interesting-field toggle button's
// `title` flips to "Remove from interesting fields" when active. Read the
// active stream from the URL — `?stream=` is set on every navigation.
const appState = await this.page.evaluate(() => {
const btnSelector = '[data-test="logs-search-index-list"] [data-test^="log-search-index-list-interesting-"][data-test$="-field-btn"]';
const fields = Array.from(document.querySelectorAll(btnSelector))
.filter(b => /remove from interesting fields/i.test(b.getAttribute('title') || ''))
.map(b => {
const m = (b.getAttribute('data-test') || '').match(/^log-search-index-list-interesting-(.+)-field-btn$/);
return m ? m[1] : null;
})
.filter(Boolean);
// De-duplicate (FieldRow renders the same button twice — once in the
// default slot and once in the `#actions` slot).
const uniqueFields = Array.from(new Set(fields));
const stream = new URLSearchParams(window.location.search).get('stream') || null;
return { fields: uniqueFields, stream };
});
const timestamp = '_timestamp';
const fields = appState?.fields || [];
// Fallback chain: URL `?stream=` → first selected stream chip on the page.
// We avoid a hardcoded stream name so this helper is reusable beyond the
// single test that currently calls it.
let stream = appState?.stream;
if (!stream) {
const chipText = await this.page
.locator('[data-test="logs-search-bar-streamname-select-stream-button"]')
.first()
.textContent()
.catch(() => null);
stream = (chipText || '').trim() || 'e2e_automate';
}
await this.sqlModeToggle.click();
const allFields = fields.includes(timestamp) ? fields : [timestamp, ...fields];
const sql = allFields.length > 1
? `SELECT ${allFields.join(',')} FROM "${stream}" ORDER BY ${timestamp} DESC`
: `SELECT * FROM "${stream}"`;
// Write the SQL into the Monaco editor via the .inputarea
await this.logsSearchBarQueryEditor.waitFor({ state: 'visible', timeout: 10000 });
await this.logsSearchBarQueryEditor.click();
const inputArea = this.logsSearchBarQueryEditor.locator('.inputarea');
await inputArea.waitFor({ state: 'visible', timeout: 5000 });
await this.page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await inputArea.fill(sql);
await this.page.waitForTimeout(600);
}
async ensureStoreOriginalDataEnabled() {
@@ -65,6 +65,10 @@ export class StreamsPage {
await this.logsPage.clickQuickModeToggle();
}
async enableQuickModeIfDisabled() {
await this.logsPage.enableQuickModeIfDisabled();
}
async clickAllFieldsButton() {
await this.logsPage.clickAllFieldsButton();
}
@@ -337,9 +337,13 @@ test.describe("Logs Query Builder - SQL Mode Toggle in Builder", () => {
await page.waitForLoadState('domcontentloaded');
const editorText = await pm.logsPage.getQueryEditorText();
expect(editorText.toLowerCase()).not.toContain('select');
// When SQL mode is turned OFF in the build tab, `onBuildQueryGenerated`
// syncs ONLY the extracted WHERE clause condition (no `WHERE` keyword) into
// the search bar — matching the search bar's filter-only mode. Verify the
// filter content (`code` field) is retained.
expect(editorText.toLowerCase()).toContain('code');
testLogger.info('SQL ON → OFF: WHERE clause shown - PASSED');
testLogger.info('SQL ON → OFF: WHERE clause filter preserved in builder-generated SQL - PASSED');
});
});
@@ -281,9 +281,12 @@ test.describe("Logs Page testcases", () => {
await pm.logsPage.clickDateTimeButton();
await page.waitForLoadState('domcontentloaded'); // Replace hard wait
await pm.logsPage.clickSQLModeToggle();
const expectedQuery = 'SELECT * FROM "e2e_automate"';
const text = await pm.logsPage.getQueryEditorText();
await expect(text.replace(/\s/g, "")).toContain(expectedQuery.replace(/\s/g, ""));
// `clickSQLModeToggle` writes a SELECT including any currently-interesting fields
// (default is just `_timestamp`). Assert structurally — `SELECT … FROM "e2e_automate"` —
// rather than pinning to a `SELECT *` shape that only the legacy code path produced.
const compact = text.replace(/\s/g, "");
expect(compact).toMatch(/^SELECT[\s\S]+FROM"e2e_automate"/);
await pm.logsPage.clickMenuLinkMetricsItem();
await pm.logsPage.clickMenuLinkLogsItem();
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {}); // Replace hard wait
@@ -21,6 +21,11 @@ test.describe("CTE Logs Queries testcases", () => {
// pattern that races the auto-search response listener.
await pm.logsPage.waitForSearchBarRefreshButton();
await pm.logsPage.runQueryAndWaitForResults();
// After the button settles, give the virtual-list renderer one pass to
// materialise row 0. waitForTableHits returns quickly when rows are present
// and gives up silently after 5 s so the individual-test assertion still
// owns the final failure message.
await pm.logsPage.waitForTableHits(5000).catch(() => {});
}
test.beforeEach(async ({ page }, testInfo) => {
@@ -43,6 +48,16 @@ test.describe("CTE Logs Queries testcases", () => {
await pageManager.logsPage.enableSqlModeIfDisabled();
await applyQueryButton(pageManager);
// Data-readiness gate: OpenObserve's WAL flush can delay indexing by a few
// seconds. If the initial SELECT * returns 0 rows (table still empty after
// the 5 s window inside applyQueryButton), run the query once more so that
// any in-progress flush has time to complete before the CTE test runs.
const hasInitialData = await pageManager.logsPage.waitForTableHits(3000);
if (!hasInitialData) {
testLogger.warn('CTE beforeEach: No rows after initial query — retrying to allow data indexing');
await applyQueryButton(pageManager);
}
testLogger.info('CTE test setup completed');
});
@@ -86,7 +101,7 @@ test.describe("CTE Logs Queries testcases", () => {
await pageManager.logsPage.clickDateTimeButton();
await pageManager.logsPage.clickRelative15MinButton();
await pageManager.logsPage.setQueryEditorValue('WITH FilteredLogs AS (SELECT * FROM "e2e_cte" WHERE str_match_ignore_case(message, \'org\')) SELECT message, kubernetes_pod_name FROM FilteredLogs');
await pageManager.logsPage.setQueryEditorValue('WITH FilteredLogs AS (SELECT * FROM "e2e_cte" WHERE message LIKE \'%org%\') SELECT message, kubernetes_pod_name FROM FilteredLogs');
// Deterministic wait — confirms Monaco model reflects the new query before Run.
await pageManager.logsPage.waitForQueryEditorValue('FilteredLogs');
await applyQueryButton(pageManager);
@@ -142,7 +157,9 @@ test.describe("CTE Logs Queries testcases", () => {
await pageManager.logsPage.clickDateTimeButton();
await pageManager.logsPage.clickRelative15MinButton();
await pageManager.logsPage.setQueryEditorValue('WITH Normalized AS (SELECT _timestamp, COALESCE(message, \'No message\') AS normalized_message FROM "e2e_cte") SELECT * FROM Normalized WHERE normalized_message LIKE \'%timeout%\'');
// Filter on the original message field inside the CTE — LIKE on a CTE-aliased
// column in the outer WHERE is unreliable in OpenObserve's SQL engine.
await pageManager.logsPage.setQueryEditorValue('WITH Normalized AS (SELECT _timestamp, COALESCE(message, \'No message\') AS normalized_message FROM "e2e_cte" WHERE str_match_ignore_case(message, \'timeout\')) SELECT * FROM Normalized');
// Deterministic wait — confirms Monaco model reflects the new query before Run.
await pageManager.logsPage.waitForQueryEditorValue('Normalized');
await applyQueryButton(pageManager);
@@ -64,7 +64,17 @@ test.describe("Logs Quickmode testcases", () => {
} catch (error) {
testLogger.warn('All fields button click failed', { error: error.message });
}
// Reset SQL mode to OFF so that clickSQLModeToggle reliably turns it ON.
// If a previous test (or localStorage restore) left the editor in SQL mode,
// clickSQLModeToggle would clear the editor instead of building a SELECT query.
try {
await pm.logsPage.disableSqlModeIfNeeded();
testLogger.info('SQL mode reset to OFF');
} catch (error) {
testLogger.warn('SQL mode reset failed, continuing', { error: error.message });
}
testLogger.info('Test setup completed');
});
@@ -82,11 +92,13 @@ test.describe("Logs Quickmode testcases", () => {
testLogger.info('Testing interesting fields and query editor');
await pm.logsPage.fillIndexFieldSearchInput("kubernetes_pod_id");
await pm.logsPage.clickInterestingFieldButton("kubernetes_pod_id");
// Use ensureFieldIsInteresting so that if the field is already starred (e.g. from
// localStorage from a previous run) we don't accidentally remove it via toggle.
await pm.logsPage.ensureFieldIsInteresting("kubernetes_pod_id");
await pm.logsPage.clickSQLModeToggle();
await pm.logsPage.waitForQueryEditorTextbox();
await pm.logsPage.expectInterestingFieldInEditor("_timestamp,kubernetes_pod_id");
testLogger.info('Interesting fields and query editor test completed');
});
test("should display quick mode toggle button", {
@@ -108,7 +120,7 @@ test.describe("Logs Quickmode testcases", () => {
await pm.logsPage.fillIndexFieldSearchInput("kubernetes_pod_id");
testLogger.info('Validated: field search input filtered to "kubernetes_pod_id"');
await pm.logsPage.clickInterestingFieldButton("kubernetes_pod_id");
await pm.logsPage.ensureFieldIsInteresting("kubernetes_pod_id");
testLogger.info('Validated: "kubernetes_pod_id" marked as an interesting field via sidebar button');
await pm.logsPage.clickSearchBarRefreshButton();
testLogger.info('Validated: search refresh triggered with interesting field selection active');
@@ -152,7 +164,7 @@ test.describe("Logs Quickmode testcases", () => {
testLogger.info('Testing interesting fields SQL mode default behavior');
await pm.logsPage.fillIndexFieldSearchInput("kubernetes_pod_id");
await pm.logsPage.clickInterestingFieldButton("kubernetes_pod_id");
await pm.logsPage.ensureFieldIsInteresting("kubernetes_pod_id");
await pm.logsPage.clickSQLModeToggle();
await pm.logsPage.waitForQueryEditorTextbox();
await pm.logsPage.expectQueryEditorVisible();
@@ -166,9 +178,9 @@ test.describe("Logs Quickmode testcases", () => {
testLogger.info('Testing interesting fields add/remove functionality');
await pm.logsPage.fillIndexFieldSearchInput("kubernetes_container_name");
await pm.logsPage.clickInterestingFieldButton("kubernetes_container_name");
await pm.logsPage.ensureFieldIsInteresting("kubernetes_container_name");
await pm.logsPage.fillIndexFieldSearchInput("level");
await pm.logsPage.clickInterestingFieldButton("level");
await pm.logsPage.ensureFieldIsInteresting("level");
await pm.logsPage.clickSQLModeToggle();
await pm.logsPage.waitForQueryEditorTextbox();
await pm.logsPage.runQueryAfterModeChange();
@@ -189,11 +201,11 @@ test.describe("Logs Quickmode testcases", () => {
testLogger.info('Testing SQL mode order by persistence after page reload');
await pm.logsPage.fillIndexFieldSearchInput("kubernetes_pod_id");
await pm.logsPage.clickInterestingFieldButton("kubernetes_pod_id");
await pm.logsPage.ensureFieldIsInteresting("kubernetes_pod_id");
await pm.logsPage.clickSQLModeToggle();
await pm.logsPage.clickSearchBarRefreshButton();
await pm.logsPage.page.reload();
await pm.logsPage.page.waitForLoadState('domcontentloaded');
await pm.logsPage.page.waitForLoadState('networkidle');
await pm.logsPage.expectQueryEditorContainsText('SELECT _timestamp,kubernetes_pod_id FROM "e2e_automate"');
testLogger.info('SQL mode order by persistence test completed');
@@ -206,7 +218,7 @@ test.describe("Logs Quickmode testcases", () => {
await pm.logsPage.fillIndexFieldSearchInput("kubernetes_pod_id");
testLogger.info('Validated: field search input filtered to "kubernetes_pod_id"');
await pm.logsPage.clickInterestingFieldButton("kubernetes_pod_id");
await pm.logsPage.ensureFieldIsInteresting("kubernetes_pod_id");
testLogger.info('Validated: "kubernetes_pod_id" marked as an interesting field');
await pm.logsPage.clickSQLModeToggle();
testLogger.info('Validated: switched to SQL mode');
@@ -236,7 +248,7 @@ test.describe("Logs Quickmode testcases", () => {
// Search and click kubernetes_pod_id field
await pm.logsPage.fillIndexFieldSearchInput("kubernetes_pod_id");
testLogger.info('Validated: field search input filtered to "kubernetes_pod_id"');
await pm.logsPage.clickInterestingFieldButton("kubernetes_pod_id");
await pm.logsPage.ensureFieldIsInteresting("kubernetes_pod_id");
testLogger.info('Validated: "kubernetes_pod_id" marked as an interesting field');
// Click on infoschema button
@@ -278,6 +278,9 @@ test.describe("Unflattened testcases", () => {
testLogger.info('Selecting kubernetes_pod_id field');
await pageManager.unflattenedPage.clickInterestingFieldButton('kubernetes_pod_id');
testLogger.info('Waiting for kubernetes_pod_id to be marked as interesting (button title flips to "Remove…")');
await pageManager.unflattenedPage.expectFieldMarkedAsInteresting('kubernetes_pod_id');
testLogger.info('Switching to SQL mode');
await pageManager.unflattenedPage.toggleSqlMode();
await page.waitForTimeout(500);
@@ -84,7 +84,7 @@ test.describe("Streaming for logs", () => {
await pm.streamsPage.navigateToLogs();
await pm.streamsPage.selectIndexAndStreamJoin();
await pm.streamsPage.kubernetesContainerNameJoinLike();
await pm.streamsPage.enableSQLMode();
// SQL mode is now auto-detected from query content — no manual toggle needed
await pm.streamsPage.selectRunQuery();
await pm.streamsPage.validateResult();
});
@@ -95,7 +95,7 @@ test.describe("Streaming for logs", () => {
await pm.streamsPage.navigateToLogs();
await pm.streamsPage.selectIndexAndStreamJoin();
await pm.streamsPage.kubernetesContainerNameLeftJoin();
await pm.streamsPage.enableSQLMode();
// SQL mode is now auto-detected from query content — no manual toggle needed
await pm.streamsPage.selectRunQuery();
await pm.streamsPage.validateResult();
});
@@ -106,7 +106,7 @@ test.describe("Streaming for logs", () => {
await pm.streamsPage.navigateToLogs();
await pm.streamsPage.selectIndexAndStreamJoin();
await pm.streamsPage.kubernetesContainerNameRightJoin();
await pm.streamsPage.enableSQLMode();
// SQL mode is now auto-detected from query content — no manual toggle needed
await pm.streamsPage.selectRunQuery();
await pm.streamsPage.validateResult();
});
@@ -127,8 +127,8 @@ test.describe("Streaming for logs", () => {
}, async ({ page }) => {
await pm.streamsPage.navigateToLogs();
await pm.streamsPage.selectIndexAndStreamJoin();
await pm.streamsPage.enableSQLMode();
await pm.streamsPage.clickQuickModeToggle();
// Ensure quick mode is ON (not a blind toggle — avoids turning it OFF if already ON)
await pm.streamsPage.enableQuickModeIfDisabled();
await pm.streamsPage.clickAllFieldsButton();
await pm.streamsPage.selectRunQuery();
await pm.streamsPage.clickInterestingFields();
@@ -140,8 +140,8 @@ test.describe("Streaming for logs", () => {
}, async ({ page }) => {
await pm.streamsPage.navigateToLogs();
await pm.streamsPage.selectIndexAndStreamJoin();
await pm.streamsPage.enableSQLMode();
await pm.streamsPage.clickQuickModeToggle();
// Ensure quick mode is ON (not a blind toggle — avoids turning it OFF if already ON)
await pm.streamsPage.enableQuickModeIfDisabled();
await pm.streamsPage.clickAllFieldsButton();
await pm.streamsPage.selectRunQuery();
await pm.streamsPage.clickInterestingFields();
@@ -154,8 +154,8 @@ test.describe("Streaming for logs", () => {
testLogger.info('Testing add/remove interesting fields');
await pm.streamsPage.navigateToLogs();
await pm.streamsPage.selectIndexAndStreamJoin();
await pm.streamsPage.enableSQLMode();
await pm.streamsPage.clickQuickModeToggle();
// Ensure quick mode is ON (not a blind toggle — avoids turning it OFF if already ON)
await pm.streamsPage.enableQuickModeIfDisabled();
await pm.streamsPage.clickAllFieldsButton();
await pm.streamsPage.selectRunQuery();
await pm.streamsPage.clickInterestingFields();
@@ -181,7 +181,7 @@ test.describe("Streaming for logs", () => {
}, async ({ page }) => {
await pm.streamsPage.navigateToLogs();
await pm.streamsPage.selectIndexStreamDefault();
await pm.streamsPage.enableSQLMode();
// SQL mode toggle removed — typing a SELECT query auto-enables SQL mode
await pm.streamsPage.clearAndFillQueryEditor('SELECT count(_timestamp) FROM "default" where code = 201');
await pm.streamsPage.selectRunQuery();
await pm.streamsPage.expectNoDataFoundForHistogram();
+14 -3
View File
@@ -359,7 +359,7 @@ export default defineComponent({
() => {
if (!monaco) return;
monaco.editor.setTheme(
store.state.theme == "dark" ? "vs-dark" : "myCustomTheme",
store.state.theme == "dark" ? "myCustomDarkTheme" : "myCustomTheme",
);
},
);
@@ -617,6 +617,17 @@ export default defineComponent({
},
});
monaco.editor.defineTheme("myCustomDarkTheme", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "string", foreground: "CE9178" },
{ token: "string.sql", foreground: "CE9178" },
{ token: "string.vrl", foreground: "CE9178" },
],
colors: {},
});
// Dispose the provider if it already exists before registering a new one
provider.value?.dispose();
registerAutoCompleteProvider();
@@ -667,7 +678,7 @@ export default defineComponent({
editorObj = monaco.editor.create(editorElement as HTMLElement, {
value: props.query?.trim(),
language: props.language,
theme: store.state.theme == "dark" ? "vs-dark" : "myCustomTheme",
theme: store.state.theme == "dark" ? "myCustomDarkTheme" : "myCustomTheme",
showFoldingControls: enableCodeFolding.value ? "always" : "never",
folding: enableCodeFolding.value,
wordWrap: "on",
@@ -874,7 +885,7 @@ export default defineComponent({
() => {
if (!monaco) return;
monaco.editor.setTheme(
store.state.theme == "dark" ? "vs-dark" : "myCustomTheme",
store.state.theme == "dark" ? "myCustomDarkTheme" : "myCustomTheme",
);
},
);
+157 -18
View File
@@ -6,28 +6,29 @@
<template>
<div class="query-editor tw:w-full tw:relative" :style="rootStyle">
<!-- AI Input Bar (shown in NL Mode) - Positioned at top -->
<!-- Height locked to 1.875rem = same as icon-toolbar expand button -->
<div
v-if="isAIMode"
class="ai-input-bar tw:p-2 tw:flex-shrink-0 tw:z-10"
:class="['ai-input-bar tw:h-[2.25rem] tw:flex tw:items-center tw:gap-2 tw:px-2 tw:flex-shrink-0 tw:z-10', props.hasExpandButton && 'tw:pr-10']"
>
<!-- Show streaming status with spinner + stop button -->
<div v-if="isGenerating" :class="aiBarStreamingClass">
<img :src="nlpIcon" alt="AI" class="tw:w-[20px] tw:h-[20px]" />
<template v-if="isGenerating">
<img :src="nlpIcon" alt="AI" class="tw:w-[20px] tw:h-[20px] tw:shrink-0" />
<OSpinner variant="dots" size="xs" />
<span class="tw:text-sm tw:flex-1">{{ streamingText || aiStatusText || t('search.analyzingQuery') }}</span>
<span class="tw:text-sm tw:flex-1 tw:truncate">{{ streamingText || aiStatusText || t('search.analyzingQuery') }}</span>
<OButton
variant="ghost-destructive"
size="icon-circle-sm"
icon-left="stop"
:data-test="`${dataTestPrefix}-ai-stop-btn`"
@click="cancelGeneration"
class="ai-stop-button"
class="ai-stop-button tw:shrink-0"
>
<OTooltip :content="t('common.stopGenerating')" />
</OButton>
</div>
</template>
<!-- Normal input when not generating -->
<div v-else class="tw:flex tw:items-center tw:gap-2">
<template v-else>
<OInput
v-model="aiInputText"
:placeholder="props.aiPlaceholder || t('search.askAIPlaceholder')"
@@ -63,7 +64,7 @@
>
<OTooltip :content="t('common.close')" />
</OButton>
</div>
</template>
</div>
<!-- Code Editor with relative positioning for floating button -->
@@ -81,8 +82,8 @@
:debounce-time="debounceTime"
@update:query="handleQueryUpdate"
@run-query="emit('run-query')"
@focus="emit('focus')"
@blur="emit('blur')"
@focus="handleEditorFocus"
@blur="handleEditorBlur"
@nlpModeDetected="handleNlpModeDetected"
@generation-start="handleGenerationStart"
@generation-end="handleGenerationEnd"
@@ -90,6 +91,17 @@
class="monaco-editor tw:w-full tw:h-full"
/>
<!-- Floating placeholder shown when editor is empty and unfocused -->
<!-- VRL (function editor): static text, always visible -->
<!-- Query editor: fades between phrases -->
<span
v-if="showPlaceholder"
class="query-editor__placeholder"
:class="{ 'query-editor__placeholder--visible': isVrlMode || placeholderFadeVisible }"
:data-test="`${dataTestPrefix}-placeholder`"
aria-hidden="true"
>{{ placeholderText }}</span>
<!-- Floating AI Icon (top-right corner of editor) - hidden when AI bar is open -->
<OButton
v-if="config.isEnterprise == 'true' && store.state.zoConfig.ai_enabled && !hideNlToggle && !isAIMode"
@@ -108,7 +120,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import CodeQueryEditor from '@/components/CodeQueryEditor.vue';
@@ -148,6 +160,7 @@ interface Props {
disableAiReason?: string; // Tooltip reason when AI is disabled
aiPlaceholder?: string; // Custom placeholder for AI input (default: 'search.askAIPlaceholder')
aiTooltip?: string; // Custom tooltip for AI send button (default: 'search.enterPrompt')
hasExpandButton?: boolean; // Reserve right padding so AI bar close btn doesn't overlap the expand btn
// Testing
dataTestPrefix?: string;
@@ -166,6 +179,7 @@ const props = withDefaults(defineProps<Props>(), {
hideNlToggle: false,
disableAi: false,
disableAiReason: '',
hasExpandButton: false,
dataTestPrefix: 'query-editor',
});
@@ -231,8 +245,8 @@ const nlpIcon = computed(() => {
// Computed: AI input field class based on theme
const aiInputFieldClass = computed(() => {
return store.state.theme === 'dark'
? 'ai-input-field ai-input-field--dark tw:flex-1'
: 'ai-input-field tw:flex-1';
? 'ai-input-field ai-input-field--dark tw:flex-1 tw:my-px'
: 'ai-input-field tw:flex-1 tw:my-px';
});
// Computed: AI streaming bar class based on theme
@@ -260,7 +274,6 @@ const rootStyle = computed(() => {
if (props.editorHeight === '100%') {
return { height: '100%' };
}
// For fixed/calc heights, apply to the root so it sizes correctly in any parent
return { height: props.editorHeight };
});
@@ -269,6 +282,16 @@ const handleQueryUpdate = (newQuery: string) => {
emit('update:query', newQuery);
};
const handleEditorFocus = () => {
editorFocused.value = true;
emit('focus');
};
const handleEditorBlur = () => {
editorFocused.value = false;
emit('blur');
};
// Handle auto-detection from editor
const handleNlpModeDetected = (isNL: boolean) => {
isNaturalLanguageDetected.value = isNL;
@@ -438,6 +461,89 @@ const handleGenerationSuccess = ({ type, message }: any) => {
emit('generation-success', { type, message });
};
// ───── Floating placeholder ─────
const editorFocused = ref(false);
const placeholderText = ref('');
const placeholderFadeVisible = ref(false);
const placeholderPhrases = computed<string[]>(() => {
if (currentLanguage.value === 'vrl') {
return ["Write a VRL function (e.g. .status = to_int!(.status))"];
}
if (currentLanguage.value === 'promql') {
return ["Write a PromQL query (e.g. rate(http_requests_total[5m]))"];
}
return [
"Add filter conditions (e.g. level='error' AND status=500 AND method='GET')",
"Write a SQL query (e.g. SELECT * FROM 'logs' WHERE level='error' LIMIT 100)",
];
});
const showPlaceholder = computed(
() => !editorFocused.value && (!props.query || !props.query.trim()),
);
// VRL (function editor) shows static text — no animation needed
const isVrlMode = computed(() => currentLanguage.value === 'vrl');
const FADE_MS = 400;
const DISPLAY_MS = 3500;
let fadeTimer: ReturnType<typeof setTimeout> | null = null;
let phraseIdx = 0;
const stopFader = () => {
if (fadeTimer) clearTimeout(fadeTimer);
fadeTimer = null;
};
const startFader = () => {
const phrases = placeholderPhrases.value;
placeholderText.value = phrases[phraseIdx % phrases.length];
placeholderFadeVisible.value = true;
if (phrases.length <= 1) return; // single phrase — show statically, no cycling
const cycle = () => {
// fade out
placeholderFadeVisible.value = false;
fadeTimer = setTimeout(() => {
phraseIdx = (phraseIdx + 1) % phrases.length;
placeholderText.value = phrases[phraseIdx];
// fade in
placeholderFadeVisible.value = true;
fadeTimer = setTimeout(cycle, DISPLAY_MS);
}, FADE_MS);
};
fadeTimer = setTimeout(cycle, DISPLAY_MS);
};
const restartFader = () => {
stopFader();
phraseIdx = 0;
placeholderFadeVisible.value = false;
placeholderText.value = placeholderPhrases.value[0] ?? '';
// Brief delay so the fade-out completes before showing new text
fadeTimer = setTimeout(startFader, FADE_MS);
};
watch(currentLanguage, () => {
stopFader();
phraseIdx = 0;
placeholderText.value = placeholderPhrases.value[0] ?? '';
placeholderFadeVisible.value = false;
if (!isVrlMode.value) {
fadeTimer = setTimeout(startFader, FADE_MS);
}
});
onMounted(() => {
placeholderText.value = placeholderPhrases.value[0] ?? '';
if (!isVrlMode.value) startFader();
});
onBeforeUnmount(stopFader);
// Watch for language prop changes
watch(() => props.defaultLanguage, (newLang) => {
if (newLang && newLang !== currentLanguage.value) {
@@ -535,6 +641,7 @@ defineExpose({
// Streaming response (for AI status display)
streamingResponse: computed(() => editorRef.value?.streamingResponse),
});
</script>
@@ -551,11 +658,34 @@ defineExpose({
overflow: hidden;
}
/* Floating AI Button (top-right corner) - matches MainLayout ai-hover-btn */
/* Floating placeholder shown when the editor is empty and unfocused.
Font + left position match Monaco's actual render settings:
glyphMargin:false, lineDecorationsWidth:3, lineNumbersMinChars:0 → ~30px gutter. */
.query-editor__placeholder {
position: absolute;
top: 0;
left: 0.8rem;
pointer-events: none;
user-select: none;
font-family: var(--font-mono, 'Menlo', 'Monaco', 'Courier New', monospace);
font-size: 0.7875rem;
line-height: 1.375rem;
color: var(--o2-text-placeholder, var(--o2-text-muted));
white-space: nowrap;
z-index: 10;
opacity: 0;
transition: opacity 0.4s ease;
}
.query-editor__placeholder--visible {
opacity: 1;
}
/* Floating AI Button (top-right corner) - leaves room for the expand button at right:0.25rem */
.ai-floating-button {
position: absolute;
top: 0.1875rem;
right: 1.375rem;
right: 2.375rem;
z-index: 100;
background: linear-gradient(135deg, rgba(139, 92, 246, 0.15) 0%, rgba(236, 72, 153, 0.15) 100%) !important;
color: white !important;
@@ -631,11 +761,20 @@ defineExpose({
border-bottom: 1px solid var(--o2-border-color);
}
.ai-input-field {
height: 1.75rem !important; /* tw:h-7 = 28px, overrides OInput's default h-8 (32px) */
}
.ai-input-field :deep(.tw\:h-8) {
height: 1.75rem !important; /* h-7 = 28px, overrides OInput's default h-8 (32px) */
}
.ai-input-field :deep(.q-field__control) {
background: white;
border-radius: 6px;
padding: 2px 8px;
min-height: 32px;
padding: 0 8px;
min-height: 18px;
height: 18px;
}
/* Remove focus border */
@@ -146,7 +146,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:query="localTab === 'sql' ? localSqlQuery : localPromqlQuery"
:disable-ai="!streamName"
:disable-ai-reason="t('search.selectStreamForAI')"
:class="(localTab === 'sql' ? localSqlQuery : localPromqlQuery) === '' && queryEditorPlaceholderFlag ? 'empty-query' : ''"
@update:query="handleQueryUpdate"
@language-change="handleLanguageChange"
@ask-ai="handleAskAI"
@@ -271,10 +270,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:debounce-time="300"
editor-height="100%"
class="tw:w-full tw:h-full"
:class="[
vrlFunctionContent == '' && functionEditorPlaceholderFlag ? 'empty-function' : '',
store.state.theme === 'dark' ? 'dark-mode-editor dark-mode' : 'light-mode-editor light-mode'
]"
:class="store.state.theme === 'dark' ? 'dark-mode-editor dark-mode' : 'light-mode-editor light-mode'"
@update:query="updateVrlFunction"
@focus="functionEditorPlaceholderFlag = false"
@blur="onBlurFunctionEditor"
@@ -610,7 +610,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:disable-ai="!streamName"
:keywords="autoCompleteKeywords"
:suggestions="autoCompleteSuggestions"
:class="(localTab === 'sql' ? localSqlQuery : localPromqlQuery) === '' && queryEditorPlaceholderFlag ? 'empty-query' : ''"
@focus="queryEditorPlaceholderFlag = false"
@blur="onBlurInlineSqlEditor"
@update:query="handleInlineQueryUpdate"
@@ -660,7 +659,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
editor-height="100%"
:disable-ai="false"
:hide-nl-toggle="false"
:class="vrlFunctionContent === '' && vrlEditorPlaceholderFlag ? 'empty-function' : ''"
@focus="vrlEditorPlaceholderFlag = false"
@blur="onBlurInlineVrlEditor"
@update:query="(v) => { vrlFunctionContent = v; handleVrlFunctionUpdate(v); }"
@@ -16,7 +16,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div
class="field-list-pagination-wrapper tw:justify-between tw:w-full tw:pt-2 tw:pb-1 tw:px-2"
class="field-list-pagination-wrapper tw:justify-between tw:w-full tw:py-px tw:px-1"
:class="showSchemaToggle || showQuickMode ? 'tw:flex' : ''"
>
<!-- Schema Toggle Buttons -->
@@ -25,7 +25,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:model-value="useUserDefinedSchemas"
@update:model-value="$emit('toggle-schema', $event)"
:data-test="`${dataTestPrefix}-fields-list-user-defined-schema-toggle`"
class="schema-field-toggle tw:mr-1 tw:p-0"
class="schema-field-toggle tw:p-0 tw:mt-1"
>
<OToggleGroupItem
v-for="opt in schemaToggleOptions"
@@ -41,8 +41,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"
>
<template v-if="opt.slot === 'user_defined_slot'">
<OIcon name="person" size="xs" class="tw:text-[12px]!"></OIcon>
<OIcon name="schema" size="xs" class="tw:text-[12px]!"></OIcon>
<OIcon name="person" size="xs" class="tw:text-[10px]!"></OIcon>
<OIcon name="schema" size="xs" class="tw:text-[10px]!"></OIcon>
<OTooltip
:data-test="`${dataTestPrefix}-fields-list-user-defined-fields-warning-tooltip`"
:content="t('search.userDefinedSchemaLabel')"
@@ -52,7 +52,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</template>
<template v-else-if="opt.slot === 'all_fields_slot'">
<OIcon name="schema" size="xs" class="tw:text-[12px]!"></OIcon>
<OIcon name="schema" size="xs" class="tw:text-[10px]!"></OIcon>
<OTooltip
:data-test="`${dataTestPrefix}-fields-list-all-fields-warning-tooltip`"
max-width="18.75rem"
@@ -69,8 +69,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template
v-else-if="opt.slot === 'interesting_fields_slot' && showQuickMode"
>
<OIcon name="info-outline" size="xs" class="tw:text-[12px]!" />
<OIcon name="schema" size="xs" class="tw:text-[12px]!"></OIcon>
<OIcon name="info-outline" size="xs" class="tw:text-[10px]!" />
<OIcon name="schema" size="xs" class="tw:text-[10px]!"></OIcon>
<OTooltip
:content="t('search.showOnlyInterestingFields')"
max-width="18.75rem"
@@ -89,17 +89,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:model-value="showOnlyInterestingFields"
@update:model-value="$emit('toggle-interesting-fields', $event)"
:data-test="`${dataTestPrefix}-fields-list-user-defined-schema-toggle`"
class="schema-field-toggle tw:mr-1"
class="schema-field-toggle"
>
<OToggleGroupItem
v-for="opt in interestingFieldsToggleOptions"
:key="opt.value"
:value="opt.value"
size="sm"
size="xs"
:data-test="opt.slot === 'all_fields_slot' ? `${dataTestPrefix}-all-fields-btn` : `${dataTestPrefix}-interesting-fields-btn`"
>
<template v-if="opt.slot === 'all_fields_slot'">
<OIcon name="schema" size="xs" class="tw:text-[12px]!"></OIcon>
<OIcon name="schema" size="xs" class="tw:text-[10px]!"></OIcon>
<OTooltip
:data-test="`${dataTestPrefix}-fields-list-all-fields-warning-tooltip`"
max-width="18.75rem"
@@ -116,8 +116,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template
v-else-if="opt.slot === 'interesting_fields_slot' && showQuickMode"
>
<OIcon name="info-outline" size="xs" class="tw:text-[12px]!" />
<OIcon name="schema" size="xs" class="tw:text-[12px]!"></OIcon>
<OIcon name="info-outline" size="xs" class="tw:text-[10px]!" />
<OIcon name="schema" size="xs" class="tw:text-[10px]!"></OIcon>
<OTooltip
:content="t('search.showOnlyInterestingFields')"
max-width="18.75rem"
@@ -131,7 +131,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- Pagination and Reset Controls -->
<div class="tw:flex tw:items-center tw:justify-end tw:gap-2">
<div class="tw:flex tw:items-center tw:justify-end tw:gap-1">
<!-- Pagination -->
<div v-if="pagesNumber > 1" class="field-list-pagination">
<OTooltip
@@ -146,7 +146,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
:data-test="`${dataTestPrefix}-fields-list-pagination-firstpage-button`"
variant="ghost"
size="xs"
size="icon-xs-sq"
:disabled="isFirstPage"
@click="$emit('first-page')"
aria-label="First page"
@@ -158,7 +158,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template v-for="page in visiblePages" :key="page">
<OButton
:variant="currentPage === page ? 'primary' : 'ghost'"
size="xs"
size="icon-xs-sq"
:data-test="`${dataTestPrefix}-fields-list-pagination-page-${page}-button`"
@click="$emit('set-page', page)"
>
@@ -170,7 +170,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
:data-test="`${dataTestPrefix}-fields-list-pagination-lastpage-button`"
variant="ghost"
size="xs"
size="icon-xs-sq"
:disabled="isLastPage"
@click="$emit('last-page')"
aria-label="Last page"
@@ -183,7 +183,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="field-list-reset">
<OIcon
name="restart-alt"
size="md"
size="sm"
:data-test="`${dataTestPrefix}-fields-list-reset-icon`"
class="tw:cursor-pointer reset-icon"
@click="$emit('reset-fields')"
@@ -280,7 +280,14 @@ const visiblePages = computed(() => {
.field-list-pagination {
display: flex;
align-items: center;
gap: 0.25rem;
gap: 0.125rem;
:deep(button) {
height: 1.375rem;
width: 1.375rem;
min-height: unset;
min-width: unset;
}
}
.field-list-reset {
@@ -289,7 +296,7 @@ const visiblePages = computed(() => {
}
.reset-icon {
font-size: 1.25rem;
font-size: 0.875rem;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
@@ -300,8 +307,14 @@ const visiblePages = computed(() => {
}
.schema-field-toggle {
:deep(.q-btn) {
padding: 0.25rem 0.5rem;
:deep([role="group"]) {
gap: 0.125rem;
}
:deep([role="group"] > *) {
gap: 0.25rem;
height: 1.375rem;
min-height: unset;
}
}
</style>
@@ -12,6 +12,7 @@
:current-page="currentPage"
:page-size-options="[50]"
:expanded-ids="expandedIds"
:loading="loading"
@update:current-page="currentPage = $event"
@update:expanded-ids="onExpandedIdsChange"
@row-click="onRowClick"
@@ -71,6 +72,65 @@
</div>
</template>
<!-- Loading skeleton -->
<template #loading>
<div
data-test="search-fieldlist-loading-skeleton"
class="tw:w-full tw:flex tw:flex-col"
>
<!-- Group 1 header -->
<div class="tw:h-7 tw:flex tw:items-center tw:justify-between tw:px-2">
<OSkeleton type="rect" class="tw:h-3 tw:w-24 tw:rounded-sm" />
<OSkeleton type="rect" class="tw:h-3 tw:w-3 tw:rounded-sm" />
</div>
<!-- Group 1 fields -->
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-3/4" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
<!-- Group 2 header -->
<div class="tw:h-7 tw:flex tw:items-center tw:justify-between tw:px-2 tw:mt-2">
<OSkeleton type="rect" class="tw:h-3 tw:w-16 tw:rounded-sm" />
<OSkeleton type="rect" class="tw:h-3 tw:w-3 tw:rounded-sm" />
</div>
<!-- Group 2 fields -->
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-4/5" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-2/3" />
</div>
<!-- Group 3 header -->
<div class="tw:h-7 tw:flex tw:items-center tw:justify-between tw:px-2 tw:mt-2">
<OSkeleton type="rect" class="tw:h-3 tw:w-32 tw:rounded-sm" />
<OSkeleton type="rect" class="tw:h-3 tw:w-3 tw:rounded-sm" />
</div>
<!-- Group 3 fields -->
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-3/4" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
</div>
</template>
<!-- After list: pagination -->
<template #after-list="bottomProps">
<div v-if="bottomProps.totalPages > 1" class="field-list-pagination">
@@ -130,6 +190,7 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OFieldList from "@/lib/lists/FieldList/OFieldList.vue";
import OFieldLabel from "@/lib/lists/FieldList/OFieldLabel.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import { b64EncodeUnicode } from "@/utils/zincutils";
import { copyToClipboard } from "@/utils/clipboard";
import { logsUtils } from "@/composables/useLogs/logsUtils";
@@ -171,6 +232,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits<{
@@ -817,11 +817,6 @@ export default defineComponent({
border-radius: 50%;
}
.empty-function .monaco-editor-background {
background-image: url("../../../assets/images/common/vrl-function.png");
background-repeat: no-repeat;
background-size: 170px;
}
// .query-tabs-container {
// width: 100%;
@@ -839,11 +839,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:query="query"
:disable-ai="!selectedStreamName"
:disable-ai-reason="t('search.selectStreamForAI')"
:class="
query == '' && queryEditorPlaceholderFlag
? 'empty-query'
: ''
"
@update:query="updateQueryValue"
@run-query="runQuery"
editor-height="calc(100vh - 190px)"
@@ -25,6 +25,9 @@ export const patternsState = ref({
lastQuery: null as SearchRequestPayload | null,
});
// Module-level abort controller — one in-flight request at a time
let patternAbortController: AbortController | null = null;
/**
* Composable for pattern extraction - completely separate from logs flow
*/
@@ -43,6 +46,8 @@ export default function usePatterns() {
return;
}
patternAbortController = new AbortController();
try {
patternsState.value.loading = true;
patternsState.value.error = null;
@@ -54,6 +59,7 @@ export default function usePatterns() {
org_identifier,
stream_name,
query: queryReq,
signal: patternAbortController.signal,
});
// console.log("[Patterns] API Response:", response.data);
@@ -94,6 +100,10 @@ export default function usePatterns() {
// "patterns found",
// );
} catch (error: any) {
// Axios wraps AbortSignal cancellations as CanceledError
if (error?.name === "CanceledError" || error?.name === "AbortError") {
return;
}
console.error("[Patterns] Error extracting patterns:", error);
patternsState.value.error =
error?.response?.data?.message ||
@@ -101,9 +111,21 @@ export default function usePatterns() {
throw error;
} finally {
patternsState.value.loading = false;
patternAbortController = null;
}
};
/**
* Cancel an in-flight pattern extraction request
*/
const cancelPatterns = () => {
if (patternAbortController) {
patternAbortController.abort();
patternAbortController = null;
}
patternsState.value.loading = false;
};
/**
* Clear patterns data
*/
@@ -126,5 +148,6 @@ export default function usePatterns() {
extractPatterns,
clearPatterns,
hasPatterns,
cancelPatterns,
};
}
@@ -143,6 +143,8 @@ export const useStreamFields = () => {
const extractFields = async () => {
schemaRequestToken.value++;
const capturedToken = schemaRequestToken.value;
searchObj.loadingStream = true;
await nextTick();
try {
searchObjDebug["extractFieldsStartTime"] = performance.now();
searchObjDebug["extractFieldsWithAPI"] = "";
@@ -907,6 +909,7 @@ export const useStreamFields = () => {
correlationFilters.restore();
searchObjDebug["extractFieldsEndTime"] = performance.now();
searchObj.loadingStream = false;
} catch (e: any) {
searchObj.loadingStream = false;
console.log("Error while extracting fields.", e);
@@ -917,21 +920,16 @@ export const useStreamFields = () => {
const loadStreamFields = async (streamName: string) => {
try {
if (streamName != "") {
searchObj.loadingStream = true;
return await getStream(
streamName,
searchObj.data.stream.streamType || "logs",
true,
).then((res) => {
searchObj.loadingStream = false;
return res;
});
);
} else {
searchObj.data.errorMsg = "No stream found in selected organization!";
}
return;
} catch (e: any) {
searchObj.loadingStream = false;
console.log("Error while loading stream fields");
}
};
@@ -25,7 +25,7 @@ const sizeClasses: Record<NonNullable<ToggleGroupItemProps["size"]>, string> = {
// Icon size mirrors the toggle size — md = sm icon (16px), sm/xs = xs icon (12px)
const iconSize: Record<NonNullable<ToggleGroupItemProps["size"]>, "xs" | "sm"> =
{
md: "sm",
md: "md",
sm: "sm",
xs: "xs",
};
@@ -46,7 +46,7 @@ const itemsRef = computed(() => props.items);
const dynamicRowHeightRef = computed(() => props.dynamicRowHeight);
const { virtualItems, totalSize, visibleRange, scrollToIndex, scrollToTop, measure, measureElement } =
const { virtualItems, totalSize, scrollMargin, visibleRange, scrollToIndex, scrollToTop, measure, measureElement } =
useVirtualScroll({
items: itemsRef,
parentRef,
@@ -101,12 +101,16 @@ defineExpose({
data-test="o2-virtual-scroll"
:style="containerStyle"
>
<!-- Spacer div that gives the scroll container the full virtual height -->
<!-- Spacer div that gives the scroll container the full virtual height.
Subtract scrollMargin so the spacer covers only the items themselves —
the header/histogram above this container already occupies that space. -->
<div
class="o2-virtual-scroll__content"
:style="{ height: `${totalSize}px`, position: 'relative' }"
:style="{ height: `${totalSize - scrollMargin}px`, position: 'relative' }"
>
<!-- One wrapper per visible virtual item — absolutely positioned -->
<!-- One wrapper per visible virtual item — absolutely positioned.
vItem.start is relative to the scroll container top (includes scrollMargin),
so subtract scrollMargin to get the offset relative to this spacer. -->
<div
v-for="vItem in virtualItems"
:key="String(vItem.key)"
@@ -116,7 +120,7 @@ defineExpose({
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vItem.start}px)`,
transform: `translateY(${vItem.start - scrollMargin}px)`,
}"
:ref="(node) => dynamicRowHeight && vItem && measureElement(node)"
>
+9 -4
View File
@@ -26,7 +26,7 @@
<div ref="scrollContainerRef" class="o-field-list__body" @scroll="onScroll">
<!-- Loading state -->
<div
v-if="loading && paginatedFields.length === 0"
v-if="loading"
class="o-field-list__loading"
>
<slot name="loading" />
@@ -34,13 +34,14 @@
<!-- Empty state -->
<div
v-else-if="!loading && paginatedFields.length === 0"
v-else-if="paginatedFields.length === 0"
class="o-field-list__empty"
>
<slot name="empty" />
</div>
<!-- Field rows -->
<!-- Field rows (hidden while loading) -->
<template v-if="!loading">
<template
v-for="row in paginatedFields"
:key="rowKey ? row[rowKey] : row.name"
@@ -109,6 +110,7 @@
<slot name="expansion" :row="row" />
</div>
</template>
</template><!-- end v-if="!loading" -->
</div>
<!-- After-list slot (pagination, toggles, etc.) -->
@@ -398,7 +400,10 @@ defineExpose({ scrollToTop });
min-height: 0;
}
&__loading,
&__loading {
width: 100%;
}
&__empty {
display: flex;
align-items: center;
+2 -2
View File
@@ -181,14 +181,14 @@ describe("ODrawer", () => {
expect(headerEl?.className).toContain("tw:shrink-0");
});
it("body does NOT have flex-1 (must not push footer to bottom on short content)", () => {
it("body has flex-1 and min-h-0 so it fills available space and the footer stays anchored", () => {
const wrapper = mount(ODrawer, {
props: { open: true },
slots: { default: '<p data-testid="body-content">body</p>' },
});
const bodyContent = wrapper.find('[data-testid="body-content"]');
const bodyEl = bodyContent.element.parentElement;
expect(bodyEl?.className).not.toContain("tw:flex-1");
expect(bodyEl?.className).toContain("tw:flex-1");
expect(bodyEl?.className).toContain("tw:min-h-0");
expect(bodyEl?.className).toContain("tw:overflow-y-auto");
});
+2 -2
View File
@@ -452,11 +452,11 @@ watch(internalOpen, (open) => {
</div>
<!-- ── Content (scrollable body) ───────────────────── -->
<!-- min-h-0 keeps the body from overflowing the flex container; footer stays after content -->
<!-- flex-1 + min-h-0: body fills remaining height so the footer always sticks to the bottom -->
<div
ref="bodyRef"
:class="[
'tw:min-h-0 tw:overflow-y-auto tw:overflow-x-hidden',
'tw:flex-1 tw:min-h-0 tw:overflow-y-auto tw:overflow-x-hidden',
'tw:text-dialog-content-text',
canScrollUp && 'tw:[box-shadow:inset_0_8px_6px_-6px_rgba(0,0,0,0.1)]',
canScrollDown && 'tw:[box-shadow:inset_0_-8px_6px_-6px_rgba(0,0,0,0.1)]',
+16 -6
View File
@@ -35,12 +35,22 @@ let cleanupFn: (() => void) | null = null;
onMounted(() => {
if (!hasDefaultSlot.value && childAnchorRef.value) {
// Walk up past any display:contents ancestors — they have no layout box
// and getBoundingClientRect() returns all-zeros, sending the tooltip to (0,0).
// This is needed because OButton wraps slot content in <span class="tw:contents">.
let candidate: Element | null = childAnchorRef.value.parentElement;
while (candidate && window.getComputedStyle(candidate).display === "contents") {
candidate = candidate.parentElement;
// Prefer the nearest previous visible sibling — the actual trigger element (e.g. the
// ToggleGroupItem button). Walking up instead when the parent is display:contents would
// land on the group container, causing all items in the group to share one hover target.
let sibling: Element | null = childAnchorRef.value.previousElementSibling;
while (sibling && (sibling as HTMLElement).style.display === "none") {
sibling = sibling.previousElementSibling;
}
let candidate: Element | null = sibling;
if (!candidate) {
// Fallback: walk up past display:contents ancestors — they have no layout box
// and getBoundingClientRect() returns all-zeros, sending the tooltip to (0,0).
candidate = childAnchorRef.value.parentElement;
while (candidate && window.getComputedStyle(candidate).display === "contents") {
candidate = candidate.parentElement;
}
}
parentEl.value = candidate;
if (parentEl.value) {
+12 -1
View File
@@ -404,7 +404,18 @@
"selectLogStream": "Logs-Stream auswählen",
"noData": "Keine Daten verfügbar",
"functionEditorLabel": "Funktionseditor",
"loadingStream": "Stream wird geladen..."
"loadingStream": "Stream wird geladen...",
"hideHistogram": "Histogramm ausblenden",
"disableQuickMode": "Schnellmodus deaktivieren",
"menuGroupSetOnce": "Einmal einstellen",
"menuGroupSavedViews": "Gespeicherte Ansichten",
"menuGroupTools": "Werkzeuge",
"menuGroupHistory": "Geschichte",
"menuGroupDownloads": "herunterladbare",
"menuGroupSchedule": "Zeitplan",
"menuGroupInspect": "Inspizieren",
"validSql": "Gültiges SQL",
"invalidSql": "Ungültige Abfrage"
},
"menu": {
"home": "Startseite",
+11
View File
@@ -166,6 +166,7 @@
"showQueryLabel": "Editors",
"showFieldLabel": "Fields",
"showHistogramLabel": "Histogram",
"hideHistogram": "Hide Histogram",
"showPatternsLabel": "Patterns",
"patternColumnHeader": "Pattern",
"occurrenceColumnHeader": "Count",
@@ -207,6 +208,16 @@
"sqlModeLabel": "SQL Mode",
"nlpModeLabel": "Natural Language Mode",
"quickModeLabel": "Quick Mode",
"disableQuickMode": "Disable Quick Mode",
"menuGroupSetOnce": "Set Once",
"menuGroupSavedViews": "Saved Views",
"menuGroupTools": "Tools",
"menuGroupHistory": "History",
"menuGroupDownloads": "Downloads",
"menuGroupSchedule": "Schedule",
"menuGroupInspect": "Inspect",
"validSql": "Valid SQL",
"invalidSql": "Invalid query",
"toggleFunctionLabel": "Function",
"labelWrap": "Wrap Content",
"messageWrapContent": "Wrap Table Content",
+12 -1
View File
@@ -404,7 +404,18 @@
"selectLogStream": "Seleccione la secuencia de registros",
"noData": "No hay datos disponibles",
"functionEditorLabel": "Editor de funciones",
"loadingStream": "Cargando Stream..."
"loadingStream": "Cargando Stream...",
"hideHistogram": "Ocultar histograma",
"disableQuickMode": "Desactivar el modo rápido",
"menuGroupSetOnce": "Establecer una vez",
"menuGroupSavedViews": "Vistas guardadas",
"menuGroupTools": "Herramientas",
"menuGroupHistory": "Historia",
"menuGroupDownloads": "Descargas",
"menuGroupSchedule": "Programar",
"menuGroupInspect": "Inspeccionar",
"validSql": "SQL válido",
"invalidSql": "Consulta no válida"
},
"menu": {
"home": "Inicio",
+12 -1
View File
@@ -404,7 +404,18 @@
"selectLogStream": "Sélectionnez le flux de journaux",
"noData": "Aucune donnée disponible",
"functionEditorLabel": "Éditeur de fonctions",
"loadingStream": "Chargement du flux..."
"loadingStream": "Chargement du flux...",
"hideHistogram": "Masquer l'histogramme",
"disableQuickMode": "Désactiver le mode rapide",
"menuGroupSetOnce": "Réglez une fois",
"menuGroupSavedViews": "Vues enregistrées",
"menuGroupTools": "Outils",
"menuGroupHistory": "L'histoire",
"menuGroupDownloads": "Téléchargements",
"menuGroupSchedule": "Calendrier",
"menuGroupInspect": "Inspectez",
"validSql": "SQL valide",
"invalidSql": "Requête non valide"
},
"menu": {
"home": "Accueil",
+12 -1
View File
@@ -404,7 +404,18 @@
"selectLogStream": "Seleziona lo stream dei log",
"noData": "Nessun dato disponibile",
"functionEditorLabel": "Editor di funzioni",
"loadingStream": "Caricamento dello stream..."
"loadingStream": "Caricamento dello stream...",
"hideHistogram": "Nascondi istogramma",
"disableQuickMode": "Disattiva la modalità rapida",
"menuGroupSetOnce": "Imposta una volta",
"menuGroupSavedViews": "Visualizzazioni salvate",
"menuGroupTools": "Utensili",
"menuGroupHistory": "Storia",
"menuGroupDownloads": "Download",
"menuGroupSchedule": "Pianificazione",
"menuGroupInspect": "Ispezionare",
"validSql": "SQL valido",
"invalidSql": "Query non valida"
},
"menu": {
"home": "Home",
+12 -1
View File
@@ -404,7 +404,18 @@
"selectLogStream": "ログストリームを選択",
"noData": "データはありません",
"functionEditorLabel": "関数エディター",
"loadingStream": "ストリームをロード中..."
"loadingStream": "ストリームをロード中...",
"hideHistogram": "ヒストグラムを非表示",
"disableQuickMode": "クイックモードを無効にする",
"menuGroupSetOnce": "1 回設定",
"menuGroupSavedViews": "保存済みビュー",
"menuGroupTools": "ツール",
"menuGroupHistory": "歴史",
"menuGroupDownloads": "ダウンロード",
"menuGroupSchedule": "スケジュール",
"menuGroupInspect": "検査",
"validSql": "有効な SQL",
"invalidSql": "クエリが無効です"
},
"menu": {
"home": "ホーム",
+12 -1
View File
@@ -404,7 +404,18 @@
"selectLogStream": "로그 스트림 선택",
"noData": "사용 가능한 데이터 없음",
"functionEditorLabel": "함수 에디터",
"loadingStream": "스트림 로드 중..."
"loadingStream": "스트림 로드 중...",
"hideHistogram": "히스토그램 숨기기",
"disableQuickMode": "퀵 모드 비활성화",
"menuGroupSetOnce": "한 번 설정",
"menuGroupSavedViews": "저장된 뷰",
"menuGroupTools": "도구",
"menuGroupHistory": "역사",
"menuGroupDownloads": "다운로드",
"menuGroupSchedule": "스케줄",
"menuGroupInspect": "검사",
"validSql": "유효한 SQL",
"invalidSql": "잘못된 쿼리"
},
"menu": {
"home": "홈",
+12 -1
View File
@@ -404,7 +404,18 @@
"selectLogStream": "Stream van logboeken selecteren",
"noData": "Geen gegevens beschikbaar",
"functionEditorLabel": "Functie-editor",
"loadingStream": "Stream wordt geladen..."
"loadingStream": "Stream wordt geladen...",
"hideHistogram": "Histogram verbergen",
"disableQuickMode": "Schakel de snelle modus uit",
"menuGroupSetOnce": "Eenmaal instellen",
"menuGroupSavedViews": "Opgeslagen weergaven",
"menuGroupTools": "Hulpmiddelen",
"menuGroupHistory": "Historie",
"menuGroupDownloads": "Downloads",
"menuGroupSchedule": "Schema",
"menuGroupInspect": "Inspecteer",
"validSql": "Geldige SQL",
"invalidSql": "Ongeldige vraag"
},
"menu": {
"home": "Home",
+12 -1
View File
@@ -404,7 +404,18 @@
"selectLogStream": "Selecione o fluxo de registros",
"noData": "Não há dados disponíveis",
"functionEditorLabel": "Editor de funções",
"loadingStream": "Carregando Stream..."
"loadingStream": "Carregando Stream...",
"hideHistogram": "Ocultar histograma",
"disableQuickMode": "Desativar o Modo Rápido",
"menuGroupSetOnce": "Definir uma vez",
"menuGroupSavedViews": "Visualizações salvas",
"menuGroupTools": "Ferramentas",
"menuGroupHistory": "História",
"menuGroupDownloads": "Downloads",
"menuGroupSchedule": "Cronograma",
"menuGroupInspect": "Inspecionar",
"validSql": "SQL válido",
"invalidSql": "Consulta inválida"
},
"menu": {
"home": "Início",
+12 -1
View File
@@ -404,7 +404,18 @@
"selectLogStream": "Günlükler akışını seçin",
"noData": "Veri yok",
"functionEditorLabel": "İşlev Düzenleyicisi",
"loadingStream": "Akış Yükleniyor..."
"loadingStream": "Akış Yükleniyor...",
"hideHistogram": "Histogramı Gizle",
"disableQuickMode": "Hızlı Mod Devre Dışı Bırak",
"menuGroupSetOnce": "Bir Kez Ayarla",
"menuGroupSavedViews": "Kayıtlı Görünümler",
"menuGroupTools": "Araçlar",
"menuGroupHistory": "Tarihçe",
"menuGroupDownloads": "İndirilenler",
"menuGroupSchedule": "Çizelge",
"menuGroupInspect": "İnceleyin",
"validSql": "Geçerli SQL",
"invalidSql": "Geçersiz sorgu"
},
"menu": {
"home": "Ana Sayfa",
+12 -1
View File
@@ -404,7 +404,18 @@
"selectLogStream": "选择日志流",
"noData": "无可用数据",
"functionEditorLabel": "函数编辑器",
"loadingStream": "正在加载直播..."
"loadingStream": "正在加载直播...",
"hideHistogram": "隐藏直方图",
"disableQuickMode": "禁用快速模式",
"menuGroupSetOnce": "设置一次",
"menuGroupSavedViews": "保存的视图",
"menuGroupTools": "工具",
"menuGroupHistory": "历史",
"menuGroupDownloads": "下载",
"menuGroupSchedule": "日程安排",
"menuGroupInspect": "检查",
"validSql": "有效的 SQL",
"invalidSql": "查询无效"
},
"menu": {
"home": "主页",
+19 -1
View File
@@ -723,7 +723,7 @@ export default defineComponent({
const isLogsMounted = ref(false);
const expandedLogs = ref([]);
const splitterModel = ref(15);
const splitterModel = ref(10);
const chartRedrawTimeout = ref(null);
const updateColumnsTimeout = ref(null);
@@ -2338,6 +2338,24 @@ export default defineComponent({
},
);
// Auto-expand splitter to 15 when either editor has >2 lines; collapse to 10 otherwise.
// Never overrides a user-set value above 15.
watch(
[() => searchObj.data.editorValue, () => searchObj.data.tempFunctionContent],
([queryValue, fnValue]) => {
const queryLines = (queryValue || '').split('\n').length;
const fnLines = (fnValue || '').split('\n').length;
const hasMoreThanTwoLines = queryLines > 2 || fnLines > 2;
if (hasMoreThanTwoLines && splitterModel.value < 15) {
splitterModel.value = 15;
} else if (!hasMoreThanTwoLines && splitterModel.value <= 15) {
splitterModel.value = 10;
}
},
{ immediate: true },
);
// Auto-apply config changes that don't require API calls (similar to dashboard)
const debouncedUpdateChartConfig = debounce(async (newVal) => {
if (searchObj.meta.logsVisualizeToggle === "visualize") {
+73 -9
View File
@@ -62,11 +62,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</div>
<div
v-if="
(!searchObj.data.stream.selectedStreamFields ||
searchObj.data.stream.selectedStreamFields.length == 0) &&
searchObj.loading == false
"
v-if="!searchObj.data.stream.selectedStream.length"
class="index-table tw:mt-1"
>
<div
@@ -152,11 +148,78 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</template>
<template #empty>
<div
data-test="logs-search-no-field-found-text"
class="tw:text-center tw:w-5/6 tw:mx-0 tw:pt-3"
>
<OIcon name="info" size="sm" class="tw:align-middle tw:mr-1" />
{{ t("search.noFieldFoundInStream") }}
</div>
</template>
<template #loading>
<div class="tw:flex tw:items-center tw:justify-center tw:w-full tw:pt-[2rem]">
<div class="tw:text-sm tw:font-medium text-weight-bold tw:w-fit tw:mx-auto tw:my-0 tw:flex-col tw:justify-items-center">
<OSpinner size="sm" />
{{ t("search.loadingStream") }}
<div
data-test="logs-indexlist-fieldlist-loading-skeleton"
class="tw:w-full tw:flex tw:flex-col"
>
<!-- Group 1 header -->
<div class="tw:h-7 tw:flex tw:items-center tw:justify-between tw:px-2">
<OSkeleton type="rect" class="tw:h-3 tw:w-24 tw:rounded-sm" />
<OSkeleton type="rect" class="tw:h-3 tw:w-3 tw:rounded-sm" />
</div>
<!-- Group 1 fields -->
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-3/4" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-4/5" />
</div>
<!-- Group 2 header -->
<div class="tw:h-7 tw:flex tw:items-center tw:justify-between tw:px-2 tw:mt-2">
<OSkeleton type="rect" class="tw:h-3 tw:w-16 tw:rounded-sm" />
<OSkeleton type="rect" class="tw:h-3 tw:w-3 tw:rounded-sm" />
</div>
<!-- Group 2 field -->
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-2/3" />
</div>
<!-- Group 3 header -->
<div class="tw:h-7 tw:flex tw:items-center tw:justify-between tw:px-2 tw:mt-2">
<OSkeleton type="rect" class="tw:h-3 tw:w-32 tw:rounded-sm" />
<OSkeleton type="rect" class="tw:h-3 tw:w-3 tw:rounded-sm" />
</div>
<!-- Group 3 fields -->
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-4/5" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-3/4" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
</div>
</template>
@@ -213,6 +276,7 @@ import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import { captureFromValuesApi } from "@/composables/useFieldValueStore";
import { saveLogsStreamType, saveLogsStream } from "@/utils/streamPersist";
import { quoteSqlIdentifierIfNeeded } from "@/utils/query/sqlIdentifiers";
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -240,7 +240,7 @@ describe("SearchResult Component", () => {
it("should compute updateTitle", () => {
const title = "Test Title";
wrapper.vm.searchObj.data.histogram.chartParams.title = title;
expect(wrapper.vm.updateTitle).toBe(title);
expect(wrapper.vm.noOfRecordsTitle).toBe(title);
});
it("should compute resetPlotChart", () => {
+227 -118
View File
@@ -31,9 +31,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
ref="searchListContainer"
>
<!-- Section header: static at top -->
<div class="tw:flex tw:min-h-[28px] tw:pt-[0.375rem] tw:shrink-0">
<div class="tw:flex tw:items-center tw:py-0.5 tw:shrink-0 result-bar">
<div
class="tw:w-2/3 tw:text-left tw:pl-4 tw:bg-amber-500 text-white tw:rounded"
class="tw:flex-1 tw:min-w-0 tw:text-left tw:pl-4 tw:bg-amber-500 text-white tw:rounded"
v-if="searchObj.data.countErrorMsg != ''"
>
<SanitizedHtmlRenderer
@@ -43,17 +43,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<div
v-else
class="tw:w-2/3 tw:text-left tw:pl-4 warning tw:flex tw:items-center"
class="tw:flex-1 tw:min-w-0 tw:text-left tw:pl-4 warning tw:flex tw:items-center tw:gap-1"
data-test="logs-search-result-title"
:data-search-state="searchObj.loading || searchObj.loadingCounter ? 'loading' : 'complete'"
:data-hits-count="searchObj.data?.queryResults?.hits?.length ?? 0"
>
{{
<span class="tw:truncate tw:min-w-0">{{
searchObj.meta.logsVisualizeToggle === "patterns"
? patternSummaryText
: noOfRecordsTitle
}}
<span v-if="searchObj.loadingCounter" class="tw:ml-3">
}}</span>
<span v-if="searchObj.loadingCounter" class="tw:shrink-0">
<OSpinner size="xs" class="search-spinner" />
</span>
<div
@@ -62,80 +62,117 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
!searchObj.loadingCounter &&
searchObj.meta.showHistogram
"
class="tw:ml-3 tw:cursor-pointer"
class="tw:shrink-0 tw:cursor-pointer"
:class="
store.state.theme == 'dark'
? 'histogram-unavailable-text'
: 'histogram-unavailable-text-light'
"
>
<!-- {{ searchObj.data.histogram.errorMsg }} -->
<OIcon name="info-outline" size="sm"> </OIcon>
<OTooltip :content="searchObj.data.histogram.errorMsg" side="top" align="center" />
</div>
<!-- Inspect Button -->
<OButton
v-if="
searchObj.data?.queryResults?.hits?.length > 0 &&
searchObj.data.lastSearchTraceId &&
config.isEnterprise == 'true' &&
config.isCloud == 'false' &&
store.state.zoConfig.search_inspector_enabled
"
variant="ghost-primary"
size="icon"
class="analyze-button inspect-button"
@click="openSearchJobInspector"
data-test="logs-inspect-button"
>
<OIcon name="troubleshoot" size="sm" />
<OTooltip :content="t('volumeInsights.searchInspectionsLabel')" />
</OButton>
<!-- Volume Analysis Button -->
<OButton
v-if="
searchObj.data?.queryResults?.hits?.length > 0 &&
!searchObj.meta.sqlMode
"
variant="ghost-primary"
size="icon"
class="analyze-button"
@click="openVolumeAnalysisDashboard"
data-test="logs-analyze-dimensions-button"
>
<OIcon name="timeline" size="sm" />
<OTooltip :content="t('volumeInsights.analyzeTooltipLogs')" />
</OButton>
<ORefreshButton
:last-run-at="searchObj.meta.lastRunAt"
:loading="searchObj.loading || searchObj.loadingHistogram"
:disabled="searchObj.loading || searchObj.loadingHistogram"
@click="$emit('run-query')"
class="tw:ml-2"
/>
</div>
<div class="tw:w-1/3 tw:pr-2 pagination-block tw:flex tw:items-center tw:justify-end tw:gap-1">
<!-- Wrap Content Button -->
<OButton
v-if="
searchObj.meta.logsVisualizeToggle === 'logs' ||
searchObj.meta.logsVisualizeToggle === 'patterns'
"
data-test="logs-search-result-wrap-table-content-btn"
variant="ghost"
size="icon"
class="wrap-content-btn"
:class="{
'wrap-content-btn--active': searchObj.meta.toggleSourceWrap,
}"
@click="
searchObj.meta.toggleSourceWrap = !searchObj.meta.toggleSourceWrap
"
>
<OIcon name="wrap-text" size="sm" />
<OTooltip :content="t('search.messageWrapContent')" />
</OButton>
<div class="tw:flex-none tw:pr-2 pagination-block tw:flex tw:items-center tw:justify-end tw:gap-1">
<!-- OVERFLOW MENU (narrow): refresh + all action buttons collapse here -->
<ODropdown v-if="shouldMoveActionsToMenu" side="bottom" align="end">
<template #trigger>
<OButton
variant="outline"
size="icon-chip"
data-test="logs-result-actions-menu-btn"
>
<OIcon name="more-horiz" size="sm" />
<OTooltip :content="t('search.moreActions')" />
</OButton>
</template>
<ODropdownItem
data-test="logs-result-refresh-menu-item"
@select="$emit('run-query')"
>
<template #icon-left><OIcon name="refresh" size="sm" /></template>
{{ t('common.refresh') }}
</ODropdownItem>
<ODropdownItem
v-if="showWrapBtn"
data-test="logs-result-wrap-menu-item"
@select="searchObj.meta.toggleSourceWrap = !searchObj.meta.toggleSourceWrap"
>
<template #icon-left><OIcon name="wrap-text" size="sm" /></template>
{{ t('search.messageWrapContent') }}
<template v-if="searchObj.meta.toggleSourceWrap" #icon-right>
<OIcon name="check" size="sm" />
</template>
</ODropdownItem>
<ODropdownItem
v-if="showInspectBtn"
data-test="logs-inspect-button"
@select="openSearchJobInspector"
>
<template #icon-left><OIcon name="troubleshoot" size="sm" /></template>
{{ t('volumeInsights.searchInspectionsLabel') }}
</ODropdownItem>
<ODropdownItem
v-if="showAnalyzeBtn"
data-test="logs-analyze-dimensions-button"
@select="openVolumeAnalysisDashboard"
>
<template #icon-left><OIcon name="timeline" size="sm" /></template>
{{ t('volumeInsights.analyzeTooltipLogs') }}
</ODropdownItem>
</ODropdown>
<!-- INLINE BUTTONS (wider container) -->
<template v-else>
<!-- Refresh in bordered wrapper -->
<div class="tw:inline-flex tw:items-center tw:border tw:border-[var(--o2-border-color)] tw:rounded-md tw:px-1 tw:h-6">
<ORefreshButton
:last-run-at="searchObj.meta.lastRunAt"
:loading="searchObj.loading || searchObj.loadingHistogram"
:disabled="searchObj.loading || searchObj.loadingHistogram"
@click="$emit('run-query')"
/>
</div>
<!-- Action buttons -->
<div
v-if="showInspectBtn || showAnalyzeBtn || showWrapBtn"
class="tw:inline-flex tw:items-center tw:gap-0.5"
>
<OButton
v-if="showInspectBtn"
variant="outline"
size="icon-chip"
@click="openSearchJobInspector"
data-test="logs-inspect-button"
>
<OIcon name="troubleshoot" size="sm" />
<OTooltip :content="t('volumeInsights.searchInspectionsLabel')" />
</OButton>
<OButton
v-if="showAnalyzeBtn"
variant="outline"
size="icon-chip"
@click="openVolumeAnalysisDashboard"
data-test="logs-analyze-dimensions-button"
>
<OIcon name="timeline" size="sm" />
<OTooltip :content="t('volumeInsights.analyzeTooltipLogs')" />
</OButton>
<OButton
v-if="showWrapBtn"
variant="outline"
size="icon-chip"
:active="searchObj.meta.toggleSourceWrap"
@click="searchObj.meta.toggleSourceWrap = !searchObj.meta.toggleSourceWrap"
data-test="logs-search-result-wrap-table-content-btn"
>
<OIcon name="wrap-text" size="sm" />
<OTooltip :content="t('search.messageWrapContent')" />
</OButton>
</div>
</template>
<OSelect
v-if="
searchObj.meta.resultGrid.showPagination &&
@@ -172,6 +209,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
?.length) || 0,
)
"
:max-pages="paginationMaxPages"
class="paginator-section"
@update:model-value="getPageData('pageChange')"
data-test="logs-search-result-pagination"
@@ -213,6 +251,38 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</div>
<div
v-else-if="
searchObj.meta.showHistogram &&
(searchObj.loadingHistogram || searchObj.loading)
"
class="histogram-skeleton"
data-test="logs-search-histogram-skeleton"
>
<!-- main row: y-axis labels + plot area -->
<div class="histogram-skeleton__main">
<div class="histogram-skeleton__y-axis">
<div class="histogram-skeleton__y-label" style="width: 1.75rem" />
<div class="histogram-skeleton__y-label" style="width: 2.25rem" />
<div class="histogram-skeleton__y-label" style="width: 1rem" />
</div>
<div class="histogram-skeleton__plot">
<div class="histogram-skeleton__bars">
<div
v-for="h in skeletonBarHeights"
:key="h.id"
class="histogram-skeleton__bar"
:style="{ height: h.pct + '%' }"
/>
</div>
</div>
</div>
<!-- x-axis labels row -->
<div class="histogram-skeleton__x-axis">
<div v-for="i in 6" :key="i" class="histogram-skeleton__x-label" />
</div>
</div>
<div
class="histogram-empty"
data-test="logs-search-no-data-histogram"
@@ -403,7 +473,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Patterns List -->
<PatternList
:patterns="patternsState?.patterns?.patterns || []"
:loading="searchObj.loading"
:loading="patternsState?.loading"
:totalLogsAnalyzed="
patternsState?.patterns?.statistics?.total_logs_analyzed
"
@@ -571,6 +641,8 @@ import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OPagination from "@/lib/navigation/Pagination/OPagination.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import { toast } from "@/lib/feedback/Toast/useToast";
export default defineComponent({
@@ -603,7 +675,9 @@ export default defineComponent({
() => import("../traces/metrics/TracesAnalysisDashboard.vue"),
),
OIcon,
},
ODropdown,
ODropdownItem,
},
emits: [
"update:scroll",
"update:datetime",
@@ -622,24 +696,6 @@ export default defineComponent({
},
},
methods: {
formatPatternSummary(stats: any, totalEvents: number, histogramMs: number) {
const patternsFound = stats?.total_patterns_found || 0;
const logsAnalyzed = (stats?.total_logs_analyzed || 0).toLocaleString();
const totalEventsStr = totalEvents
? totalEvents.toLocaleString()
: logsAnalyzed;
// Combine histogram time + pattern extraction time
const patternMs = stats?.extraction_time_ms || 0;
const totalTimeMs = histogramMs + patternMs;
return this.$t("search.pattern_summary", {
totalEvents: totalEventsStr,
patternsFound: patternsFound,
logsAnalyzed: logsAnalyzed,
totalTime: totalTimeMs,
});
},
handleColumnSizesUpdate(newColSizes: any) {
const prevColSizes =
this.searchObj.data.resultGrid?.colSizes[
@@ -827,9 +883,33 @@ export default defineComponent({
// https://stackoverflow.com/questions/6491463/accessing-nested-javascript-objects-and-arrays-by-string-path
const { t } = useI18n();
const store = useStore();
const searchListContainer = ref(null);
const noOfRecordsTitle = ref("");
const patternSummaryText = ref("");
const searchListContainer = ref<HTMLElement | null>(null);
// Responsive: observe the outer container (reacts to splitter + window resize)
const containerWidth = ref(9999);
let containerResizeObserver: ResizeObserver | null = null;
// match shouldMoveActionsToMenu threshold: 3 pages when narrow, 5 when wide
const paginationMaxPages = computed(() => containerWidth.value < 700 ? 3 : 5);
const noOfRecordsTitle = computed<string>(
() => (searchObj.data.histogram.chartParams.title as string) || "",
);
const patternSummaryText = computed<string>(() => {
const stats = patternsState.value?.patterns?.statistics;
if (!stats) return "";
const patternsFound = stats.total_patterns_found || 0;
const logsAnalyzed = (stats.total_logs_analyzed || 0).toLocaleString();
const totalEvents = searchObj.data.queryResults?.total || stats.total_logs_analyzed || 0;
const totalEventsStr = totalEvents ? totalEvents.toLocaleString() : logsAnalyzed;
const totalTimeMs = (searchObj.data.queryResults?.took || 0) + (stats.extraction_time_ms || 0);
return t("search.pattern_summary", {
totalEvents: totalEventsStr,
patternsFound,
logsAnalyzed,
totalTime: totalTimeMs,
});
});
const scrollPosition = ref(0);
const rowsPerPageOptions = [10, 25, 50, 100];
const disableMoreErrorDetails = ref(false);
@@ -1113,10 +1193,20 @@ export default defineComponent({
onMounted(() => {
reDrawChart();
window.addEventListener("themeColorChanged", handleThemeColorChange);
// Observe the outer container so breakpoints respond to splitter + window resize
if (searchListContainer.value) {
containerWidth.value = searchListContainer.value.getBoundingClientRect().width;
containerResizeObserver = new ResizeObserver((entries) => {
containerWidth.value = entries[0]?.contentRect.width ?? 0;
});
containerResizeObserver.observe(searchListContainer.value);
}
});
onBeforeUnmount(() => {
window.removeEventListener("themeColorChanged", handleThemeColorChange);
containerResizeObserver?.disconnect();
// Clear any pending debounce timer
if (debounceTimer) {
clearTimeout(debounceTimer);
@@ -1508,6 +1598,14 @@ export default defineComponent({
return searchObj.meta.showHistogram && searchObj.loadingHistogram == true;
});
// 250 bars × 9px (7px bar + 2px gap) = 2250px — covers any viewport width.
// Heights cycle through a realistic uneven pattern so it looks like real log data.
const SKELETON_HEIGHTS = [45,72,58,88,62,42,78,52,73,38,68,83,48,68,44,92,62,38,72,56,32,82,48,64,38,88,68,44,78,52,40,95,55,70,30,85,65,50,75,42];
const skeletonBarHeights = Array.from({ length: 250 }, (_, i) => ({
id: i,
pct: SKELETON_HEIGHTS[i % SKELETON_HEIGHTS.length],
}));
const sendToAiChat = (value: any, append: boolean = true) => {
emit("sendToAiChat", value, append);
};
@@ -1565,6 +1663,15 @@ export default defineComponent({
},
);
watch(
() => patternsState.value.loading,
(loading, wasLoading) => {
if (wasLoading && !loading) {
searchObj.meta.lastRunAt = Date.now();
}
},
);
watch(resetPlotChart, (newVal) => {
if (newVal) {
plotChart.value = {};
@@ -1665,6 +1772,8 @@ export default defineComponent({
config,
plotChart,
searchObj,
containerWidth,
paginationMaxPages,
patternsState,
updatedLocalLogFilterField,
byString,
@@ -1709,6 +1818,7 @@ export default defineComponent({
refreshPagination,
refreshJobPagination,
histogramLoader,
skeletonBarHeights,
sendToAiChat,
closeTable,
getRowIndex,
@@ -1752,12 +1862,6 @@ export default defineComponent({
findFTSFields() {
return this.searchObj.data.stream.selectedStreamFields;
},
updateTitle() {
return this.searchObj.data.histogram.chartParams.title;
},
updatePatternSummary() {
return this.patternsState?.patterns?.statistics;
},
reDrawChartData() {
return this.searchObj.data.histogram;
},
@@ -1774,6 +1878,31 @@ export default defineComponent({
: this.searchObj.data.datetime.endTime,
};
},
// Responsive: collapse action buttons to overflow menu when container is narrow
shouldMoveActionsToMenu() {
return this.containerWidth < 700;
},
showInspectBtn() {
return (
this.searchObj.data?.queryResults?.hits?.length > 0 &&
this.searchObj.data.lastSearchTraceId &&
this.config.isEnterprise == "true" &&
this.config.isCloud == "false" &&
this.store.state.zoConfig.search_inspector_enabled
);
},
showAnalyzeBtn() {
return (
this.searchObj.data?.queryResults?.hits?.length > 0 &&
!this.searchObj.meta.sqlMode
);
},
showWrapBtn() {
return (
this.searchObj.meta.logsVisualizeToggle === "logs" ||
this.searchObj.meta.logsVisualizeToggle === "patterns"
);
},
},
watch: {
toggleWrapFlag() {
@@ -1782,26 +1911,6 @@ export default defineComponent({
findFTSFields() {
this.extractFTSFields();
},
updateTitle() {
this.noOfRecordsTitle = this.searchObj.data.histogram.chartParams.title;
},
updatePatternSummary() {
if (this.patternsState?.patterns?.statistics) {
// Reuse the same summary logic from PatternStatistics component
const stats = this.patternsState.patterns.statistics;
const totalEvents =
this.searchObj.data.queryResults?.total ||
stats.total_logs_analyzed ||
0;
const histogramMs = this.searchObj.data.queryResults?.took || 0;
this.patternSummaryText = this.formatPatternSummary(
stats,
totalEvents,
histogramMs,
);
}
},
reDrawChartData: {
deep: true,
handler: function () {
+36 -24
View File
@@ -217,19 +217,30 @@ class="tw:mr-1" />
class="logs-skel-row tw:flex tw:items-center tw:w-full"
:style="{ animationDelay: `${(r - 1) * 40}ms` }"
>
<td
v-for="(header, c) in headers"
:key="header.id"
class="tw:px-2 tw:overflow-hidden"
:class="c === 0 ? 'tw:pl-4' : ''"
:style="skelTdStyle(header, c)"
>
<!-- No columns loaded yet (first page load) — full-width shimmer bar -->
<td v-if="!headers?.length" class="tw:w-full tw:px-4 tw:overflow-hidden">
<span
class="logs-skel-pill tw:inline-block tw:h-3 tw:rounded-md"
:style="{ width: c === 0 ? `${SKEL_TIMESTAMP_PX}px` : `${skelCellWidth(r - 1, c)}%` }"
:style="{ width: `${skelCellWidth(r - 1, 0)}%` }"
aria-hidden="true"
/>
</td>
<!-- Columns available — per-column aligned shimmer pills -->
<template v-else>
<td
v-for="(header, c) in headers"
:key="header.id"
class="tw:px-2 tw:overflow-hidden"
:class="c === 0 ? 'tw:pl-4' : ''"
:style="skelTdStyle(header, c)"
>
<span
class="logs-skel-pill tw:inline-block tw:h-3 tw:rounded-md"
:style="{ width: c === 0 ? `${SKEL_TIMESTAMP_PX}px` : `${skelCellWidth(r - 1, c)}%` }"
aria-hidden="true"
/>
</td>
</template>
</tr>
</tbody>
@@ -272,9 +283,7 @@ class="tw:mr-1" />
? 'tw:bg-zinc-700'
: 'tw:bg-zinc-300'
: !(formattedRows[virtualRow.index]?.original as any)?.isExpandedRow
? virtualRow.index % 2 === 0
? 'log-row-base'
: 'log-row-alt'
? 'log-row-base'
: '',
!(formattedRows[virtualRow.index]?.original as any)?.isExpandedRow
? 'table-row-hover'
@@ -796,7 +805,7 @@ const headerGroups = computed(() => table?.getHeaderGroups()[0]);
const headers = computed(() => headerGroups.value.headers);
// Skeleton loading helpers — mirrors OTable shimmer pattern
const SKEL_ROW_COUNT = 12;
const SKEL_ROW_COUNT = 30;
const SKEL_BASE_WIDTHS = [55, 70, 60, 45, 65, 50, 75, 40, 58, 68, 48, 62];
const SKEL_JITTER = [0, 6, -4, 3, -2, 5, -3, 2, -5, 4, -1, 6];
// Timestamp column: pill sized to "2026-06-02 12:09:00.349" (23 chars × 7.2 px/char in monospace 12 px)
@@ -1176,15 +1185,11 @@ defineExpose({
height: 0.75rem !important;
}
// Log table row surface colours — driven by CSS vars per theme
// Log table row surface colour — uniform background, separator via border-bottom only
.log-row-base {
background-color: var(--o2-log-table-row-bg);
}
.log-row-alt {
background-color: var(--o2-log-table-row-alt-bg);
}
.table-row-hover {
transition: background-color 0.12s ease-in-out, box-shadow 0.12s ease-in-out;
border-bottom-color: var(--o2-log-table-row-border) !important;
@@ -1229,21 +1234,28 @@ defineExpose({
border-bottom: 1px solid var(--o2-log-table-row-border);
height: 29px;
background-color: var(--o2-log-table-row-bg);
&:nth-child(even) {
background-color: var(--o2-log-table-row-alt-bg);
}
}
.logs-skel-pill {
// Light mode — matches histogram-skeleton --hsk-bar (grey-100 #f5f5f5)
background: linear-gradient(
90deg,
var(--color-skeleton-base) 0%,
var(--color-skeleton-highlight) 50%,
var(--color-skeleton-base) 100%
var(--color-grey-100) 0%,
rgba(255, 255, 255, 0.65) 50%,
var(--color-grey-100) 100%
);
background-size: 200% 100%;
animation: logs-skel-shimmer 1.5s ease-in-out infinite;
// Dark mode — matches histogram-skeleton dark --hsk-bar (grey-600 #525252)
.body--dark & {
background: linear-gradient(
90deg,
var(--color-grey-600) 0%,
rgba(255, 255, 255, 0.03) 50%,
var(--color-grey-600) 100%
);
}
}
@keyframes logs-skel-shimmer {
@@ -119,17 +119,64 @@
<template #loading>
<div
data-test="logs-fieldlist-loading-skeleton"
class="tw:flex tw:flex-col"
class="tw:w-full tw:flex tw:flex-col"
>
<div
v-for="i in 8"
:key="i"
class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]"
>
<OSkeleton
type="rect"
class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:flex-shrink-0"
/>
<!-- Group 1 header -->
<div class="tw:h-7 tw:flex tw:items-center tw:justify-between tw:px-2">
<OSkeleton type="rect" class="tw:h-3 tw:w-24 tw:rounded-sm" />
<OSkeleton type="rect" class="tw:h-3 tw:w-3 tw:rounded-sm" />
</div>
<!-- Group 1 fields -->
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-3/4" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-4/5" />
</div>
<!-- Group 2 header -->
<div class="tw:h-7 tw:flex tw:items-center tw:justify-between tw:px-2 tw:mt-2">
<OSkeleton type="rect" class="tw:h-3 tw:w-16 tw:rounded-sm" />
<OSkeleton type="rect" class="tw:h-3 tw:w-3 tw:rounded-sm" />
</div>
<!-- Group 2 field -->
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-2/3" />
</div>
<!-- Group 3 header -->
<div class="tw:h-7 tw:flex tw:items-center tw:justify-between tw:px-2 tw:mt-2">
<OSkeleton type="rect" class="tw:h-3 tw:w-32 tw:rounded-sm" />
<OSkeleton type="rect" class="tw:h-3 tw:w-3 tw:rounded-sm" />
</div>
<!-- Group 3 fields -->
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-4/5" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:w-3/4" />
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:px-3 tw:py-[0.375rem]">
<OSkeleton type="rect" class="tw:w-[0.875rem] tw:h-[0.875rem] tw:rounded-sm tw:shrink-0" />
<OSkeleton type="text" class="tw:flex-1" />
</div>
</div>
@@ -21,13 +21,43 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@update:open="$emit('update:modelValue', $event)"
:width="90"
:title="t('search.patternDetailsTitle')"
:sub-title="selectedPattern ? t('search.patternXofY', { index: selectedPattern.index + 1, total: totalPatterns }) : undefined"
:subTitle="selectedPattern ? t('search.patternXofY', { index: selectedPattern.index + 1, total: totalPatterns }) : undefined"
>
<template #header>
<div class="tw:flex-1 tw:min-w-0 tw:flex tw:flex-col tw:gap-0.5 tw:overflow-hidden">
<!-- Row 1: level badge · title · token & slot OBadges (right of title) -->
<div class="tw:flex tw:items-center tw:gap-2 tw:min-w-0">
<span
v-if="patternLevelInfo"
class="tw:shrink-0 tw:inline-flex tw:items-center tw:px-1.5 tw:py-0.5 tw:rounded tw:text-[0.625rem] tw:font-bold tw:uppercase tw:tracking-wide tw:text-white"
:style="{ backgroundColor: patternLevelInfo.color }"
>
{{ patternLevelInfo.level }}
</span>
<h4 class="tw:font-semibold tw:text-[var(--o2-text-heading)] tw:truncate tw:min-w-0 tw:text-base tw:leading-tight tw:m-0">
{{ selectedPattern?.pattern?.description || t('search.patternDetailsTitle') }}
</h4>
<template v-if="selectedPattern">
<OBadge variant="default-soft" size="sm" class="tw:shrink-0">
{{ selectedTemplateTokens.length }} {{ selectedTemplateTokens.length === 1 ? 'token' : 'tokens' }}
</OBadge>
<OBadge variant="default-soft" size="sm" class="tw:shrink-0">
{{ patternWildcardCount }} {{ patternWildcardCount === 1 ? 'variable slot' : 'variable slots' }}
</OBadge>
</template>
</div>
<!-- Row 2: full-width module path, truncates at edge -->
<code
v-if="selectedPattern && patternPathToken"
class="tw:block tw:w-full tw:truncate tw:text-[var(--o2-text-code)] tw:font-mono tw:text-[0.6875rem] tw:text-[var(--o2-text-caption)]"
>{{ patternPathToken }}</code>
</div>
</template>
<div class="tw:px-5 tw:py-3">
<template v-if="selectedPattern">
<!-- Statistics -->
<div class="tw-mb-[1rem]">
<div class="tw:text-sm tw:font-medium text-weight-medium tw-mb-[0.375rem]">
<div class="tw:mb-4">
<div class="tw:text-sm tw:font-medium tw:mb-1.5">
{{ t("search.patternStatistics") }}
</div>
<div class="tw:flex tw:gap-3">
@@ -83,10 +113,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="tw:mt-3"
>
<div
class="tw-rounded tw-border tw-border-solid tw-border-negative tw-px-3 tw-py-2 tw-flex tw-gap-3 tw-items-start"
:class="store.state.theme === 'dark' ? 'tw:bg-gray-800' : 'bg-white'"
class="tw:rounded tw:border tw:border-solid tw:border-[var(--o2-status-error-border)] tw:px-3 tw:py-2 tw:flex tw:gap-3 tw:items-start"
:class="store.state.theme === 'dark' ? 'tw:bg-gray-800' : 'tw:bg-white'"
>
<OIcon name="warning" size="sm" class="tw-mt-[2px] tw-flex-shrink-0" />
<OIcon name="warning" size="sm" class="tw:mt-0.5 tw:shrink-0" />
<div>
<div class="text-weight-bold tw:text-red-500">{{ t("search.patternAnomalyDetected") }}</div>
<div
@@ -108,12 +138,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- Variables Summary -->
<div class="tw-mb-[1rem]">
<div class="tw:text-sm tw:font-medium text-weight-medium tw-mb-[0.375rem]">
<div class="tw:mb-4">
<div class="tw:text-sm tw:font-medium tw:mb-1.5">
{{ t("search.patternVariablesHeader") }}
</div>
<div
class="tw:px-[0.625rem] tw:py-[0.375rem] tw:rounded tw:border-l-[0.25rem] tw:border-solid tw:border-l-[var(--q-primary)]"
class="tw:px-2.5 tw:py-1.5 tw:rounded tw:border-l-4 tw:border-solid tw:border-l-[var(--o2-primary-color)]"
:class="
store.state.theme === 'dark' ? 'tw:bg-gray-800' : 'tw:bg-gray-100'
"
@@ -127,18 +157,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- Pattern Template -->
<div class="tw-mb-[1rem]">
<div class="tw:text-sm tw:font-medium text-weight-medium tw-mb-[0.375rem]">
<div class="tw:mb-4">
<div class="tw:text-sm tw:font-medium tw:mb-1.5">
{{ t("search.patternTemplate") }}
</div>
<div
class="tw-px-[0.625rem] tw-py-[0.375rem] pattern-detail-text tw-text-[0.8125rem] tw-leading-[1.6] tw-rounded tw-border-l-[0.25rem] tw-border-solid tw-border-l-[var(--q-primary)] tw-break-all tw-flex tw-flex-wrap tw-items-baseline tw-gap-x-[2px] tw-gap-y-[2px]"
class="tw:px-2.5 tw:py-1.5 pattern-detail-text tw:text-[0.8125rem] tw:leading-[1.6] tw:rounded tw:border-l-4 tw:border-solid tw:border-l-[var(--o2-primary-color)] tw:break-all tw:flex tw:flex-wrap tw:items-baseline tw:gap-x-[2px] tw:gap-y-[2px]"
:class="
store.state.theme === 'dark' ? 'tw:bg-gray-800' : 'tw:bg-gray-100'
"
>
<template v-for="(tok, i) in selectedTemplateTokens" :key="i">
<span v-if="tok.kind === 'text'" class="tw-whitespace-pre">{{ tok.value }}</span>
<span v-if="tok.kind === 'text'" class="tw:whitespace-pre">{{ tok.value }}</span>
<span
v-else
class="tw:inline-flex"
@@ -163,9 +193,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
selectedPattern.pattern.variables &&
selectedPattern.pattern.variables.length > 0
"
class="tw:mb-[1rem]"
class="tw:mb-4"
>
<div class="tw:text-sm tw:font-medium text-weight-medium tw-mb-[0.375rem]">
<div class="tw:text-sm tw:font-medium tw:mb-1.5">
{{ t("search.patternVariablesWithCount", { count: selectedPattern.pattern.variables.length }) }}
</div>
<OTable
@@ -174,6 +204,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
row-key="index"
pagination="none"
:show-global-filter="false"
:max-height="undefined"
class="tw:w-full tw:border tw:border-solid tw:border-[var(--o2-border-color)]"
>
<template #cell-name="{ row }">
@@ -205,7 +236,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
"
class="tw:mb-[1rem]"
>
<div class="tw:text-sm tw:font-medium text-weight-medium tw-mb-[0.375rem]">
<div class="tw:text-sm tw:font-medium tw:mb-1.5">
{{ t("search.patternExampleLogsWithCount", { count: selectedPattern.pattern.examples.length }) }}
</div>
<div
@@ -298,6 +329,7 @@ import {
} from "@/composables/useLogs/useTemplateTokenizer";
import WildcardValuePopover from "./WildcardValuePopover.vue";
import useWildcardHover from "./useWildcardHover";
import { extractStatusFromTemplate } from "@/utils/logs/statusParser";
const props = defineProps<{
modelValue: boolean;
@@ -322,6 +354,8 @@ const {
onPopoverLeave,
} = useWildcardHover();
const isDark = computed(() => store.state.theme === "dark");
const selectedTemplateTokens = computed(() =>
tokenizeTemplate(
props.selectedPattern?.pattern?.template ?? "",
@@ -329,6 +363,23 @@ const selectedTemplateTokens = computed(() =>
),
);
const patternLevelInfo = computed(() => {
const template = props.selectedPattern?.pattern?.template ?? "";
if (!template) return null;
const info = extractStatusFromTemplate(template, isDark.value);
return info.level !== "info" || /\b(info|information)\b/i.test(template) ? info : null;
});
const patternPathToken = computed(() => {
const tokens = selectedTemplateTokens.value;
const first = tokens.find((t) => t.kind === "text" && t.value.trim());
return first?.value?.trim() ?? "";
});
const patternWildcardCount = computed(() =>
selectedTemplateTokens.value.filter((t) => t.kind !== "text").length,
);
const anomalyExplanationForSelected = computed(() =>
anomalyExplanation(props.selectedPattern?.pattern ?? {}, t),
);
@@ -151,14 +151,14 @@ describe("PatternList", () => {
});
});
it("should display loading spinner when loading is true", () => {
const spinner = wrapper.find('[data-test="pattern-list-loading-indicator"]');
expect(spinner.exists()).toBe(true);
it("should display loading skeleton when loading is true", () => {
const skeleton = wrapper.find('[data-test="pattern-list-loading-skeleton"]');
expect(skeleton.exists()).toBe(true);
});
it("should display loading text", () => {
const loadingText = wrapper.text();
expect(loadingText).toContain("Extracting patterns from logs");
const skeleton = wrapper.find('[data-test="pattern-list-loading-skeleton"]');
expect(skeleton.attributes("aria-label")).toContain("Extracting patterns from logs");
});
it("should not display virtual scroll when loading", () => {
+123 -10
View File
@@ -16,8 +16,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div class="tw:flex tw:flex-col">
<!-- Patterns Table -->
<div v-if="patterns?.length > 0" class="tw:flex tw:flex-col">
<!-- Patterns Table — hidden during loading so skeleton always shows on re-run -->
<div v-if="!loading && patterns?.length > 0" class="tw:flex tw:flex-col">
<!-- Table Header -->
<div
class="tw:flex tw:items-center tw:border-b tw:border-[var(--o2-border-color)]"
@@ -90,17 +90,56 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OVirtualScroll>
</div>
<!-- Loading State -->
<!-- Loading State — Skeleton Rows (same shimmer style as logs table) -->
<div
v-else-if="loading"
class="tw:flex tw:flex-col tw:items-center tw:justify-center tw:py-[3rem]"
class="tw:flex tw:flex-col"
data-test="pattern-list-loading-skeleton"
aria-busy="true"
aria-live="polite"
aria-label="Extracting patterns from logs"
>
<OSpinner size="lg" data-test="pattern-list-loading-indicator" />
<!-- Header skeleton -->
<div
class="tw:mt-3 tw:text-sm"
:class="store.state.theme === 'dark' ? 'tw:text-gray-400' : 'tw:text-gray-400'"
class="pattern-skel-header tw:flex tw:items-center tw:border-b tw:border-[var(--o2-border-color)]"
style="background: var(--o2-table-header-bg); min-width: 100%"
>
Extracting patterns from logs...
<div class="tw:flex-1 tw:min-w-0 tw:px-2">
<span class="pattern-skel-pill tw:inline-block tw:h-3 tw:w-16 tw:rounded-sm" aria-hidden="true" />
</div>
<div class="tw:w-24 tw:flex-shrink-0 tw:px-2 tw:flex tw:justify-end">
<span class="pattern-skel-pill tw:inline-block tw:h-3 tw:w-14 tw:rounded-sm" aria-hidden="true" />
</div>
<div class="tw:w-20 tw:flex-shrink-0 tw:px-2" />
</div>
<!-- Skeleton rows mimicking PatternCard layout -->
<div
v-for="(skeletonWidth, n) in SKELETON_WIDTHS"
:key="n"
class="pattern-skel-row tw:flex tw:items-center tw:border-b tw:border-[var(--o2-border-color)] tw:relative"
:style="{ animationDelay: `${n * 40}ms` }"
>
<!-- Left accent bar -->
<span class="tw:absolute tw:left-0 tw:inset-y-0 tw:w-1 pattern-skel-pill" aria-hidden="true" />
<!-- Pattern column -->
<div class="tw:flex-1 tw:min-w-0 tw:px-2 tw:pl-3">
<span class="pattern-skel-pill tw:inline-block tw:h-3 tw:rounded-sm" :class="skeletonWidth" aria-hidden="true" />
</div>
<!-- Count column -->
<div class="tw:w-24 tw:flex-shrink-0 tw:px-2 tw:flex tw:flex-col tw:items-end tw:gap-1">
<span class="pattern-skel-pill tw:inline-block tw:h-3 tw:w-12 tw:rounded-sm" aria-hidden="true" />
<span class="pattern-skel-pill tw:inline-block tw:h-2 tw:w-10 tw:rounded-sm" aria-hidden="true" />
</div>
<!-- Actions column — 3 icon-sized circles -->
<div class="tw:w-20 tw:flex-shrink-0 tw:px-2 tw:flex tw:items-center tw:justify-center tw:gap-1">
<span
v-for="i in 3"
:key="i"
class="pattern-skel-pill tw:inline-block tw:w-7 tw:h-7 tw:rounded-full tw:shrink-0"
aria-hidden="true"
/>
</div>
</div>
</div>
@@ -132,7 +171,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
<!-- Bottom spacer so the last row isn't flush with the container edge -->
<div v-if="patterns?.length > 0" class="tw:h-4" />
<div v-if="!loading && patterns?.length > 0" class="tw:h-4" />
<!-- Wildcard hover popover (outside q-virtual-scroll to avoid DOM recycling conflicts) -->
<WildcardValuePopover
@@ -153,9 +192,31 @@ import { useI18n } from "vue-i18n";
import PatternCard from "./PatternCard.vue";
import WildcardValuePopover from "./WildcardValuePopover.vue";
import useWildcardHover from "./useWildcardHover";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OVirtualScroll from "@/lib/core/VirtualScroll/OVirtualScroll.vue";
const SKELETON_WIDTHS = [
"tw:w-3/4",
"tw:w-2/3",
"tw:w-11/12",
"tw:w-1/2",
"tw:w-5/6",
"tw:w-7/12",
"tw:w-4/5",
"tw:w-2/3",
"tw:w-3/4",
"tw:w-9/12",
"tw:w-1/2",
"tw:w-5/6",
"tw:w-7/12",
"tw:w-11/12",
"tw:w-3/5",
"tw:w-4/5",
"tw:w-2/3",
"tw:w-3/4",
"tw:w-1/2",
"tw:w-5/6",
];
const props = defineProps<{
patterns: any[];
loading: boolean;
@@ -181,3 +242,55 @@ const {
onPopoverLeave,
} = useWildcardHover();
</script>
<style scoped lang="scss">
// ── Pattern list loading skeleton ────────────────────────────────────────
// Matches the shimmer style used by the logs table (TenstackTable.vue)
// but at the slightly lighter grey-100 / grey-600 palette for visual parity.
.pattern-skel-header {
min-height: 2rem;
}
.pattern-skel-row {
opacity: 0;
height: 2rem;
animation: pattern-skel-row-in 320ms ease-out forwards;
background-color: var(--o2-log-table-row-bg, transparent);
}
.pattern-skel-pill {
background: linear-gradient(
90deg,
var(--color-grey-100) 0%,
rgba(255, 255, 255, 0.65) 50%,
var(--color-grey-100) 100%
);
background-size: 200% 100%;
animation: pattern-skel-shimmer 1.5s ease-in-out infinite;
.body--dark & {
background: linear-gradient(
90deg,
var(--color-grey-600) 0%,
rgba(255, 255, 255, 0.03) 50%,
var(--color-grey-600) 100%
);
}
}
@keyframes pattern-skel-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@keyframes pattern-skel-row-in {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.pattern-skel-row { opacity: 1; animation: none; }
.pattern-skel-pill { animation: none; }
}
</style>
@@ -192,15 +192,17 @@ describe("WildcardValuePopover", () => {
});
describe("color scheme", () => {
it("should apply correct header color for <*> token", () => {
const headerEl = wrapper.find(".wildcard-popover-header");
it("should render the header for <*> token", () => {
const headerEl = wrapper.find(".wcp__header");
expect(headerEl.exists()).toBe(true);
expect(headerEl.classes().some((c) => c.includes("wildcard-header"))).toBe(true);
// bar color class is applied to the fill, not the header
const barFill = wrapper.find(".wcp__bar-fill");
expect(barFill.exists()).toBe(true);
});
it("should apply correct header color for <:IP> token", () => {
it("should render the header for <:IP> token", () => {
wrapper = mountComponent({ token: "<:IP>", displayValues: [{ value: "10.0.0.1", count: 5 }] });
const headerEl = wrapper.find(".wildcard-popover-header");
const headerEl = wrapper.find(".wcp__header");
expect(headerEl.exists()).toBe(true);
});
});
@@ -18,73 +18,75 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
v-show="visible && !!anchorEl"
ref="popoverRef"
class="wildcard-popover"
:class="popoverPositionClass"
class="wcp"
:class="{ 'wcp--flip-up': flipUpward }"
:style="popoverStyle"
data-test="wildcard-value-popover"
@mouseenter="$emit('popoverEnter')"
@mouseleave="$emit('popoverLeave')"
>
<!-- Header -->
<div class="wildcard-popover-header" :class="headerClass">
<span class="wildcard-popover-header-token">{{ wildcardLabel(token, displayValues) }}</span>
<!-- ── Header ── -->
<div class="wcp__header">
<div class="wcp__header-left">
<OBadge variant="default-soft" size="sm" class="wcp__type-badge">
{{ tokenType }}
</OBadge>
<span class="wcp__title">Variable slot · {{ tokenType }}</span>
</div>
<div class="wcp__header-right">
<span class="wcp__unique-count">{{ displayValues.length }}</span>
<span class="wcp__unique-label">unique</span>
</div>
</div>
<!-- Body - bar chart distribution -->
<div class="wildcard-popover-body">
<!-- ── Value rows ── -->
<div class="wcp__body">
<div
v-for="(item, i) in displayValues.slice(0, 10)"
:key="i"
class="wildcard-value-row"
class="wcp__row"
:data-test="`wildcard-value-row-${i}`"
>
<span class="wildcard-value-count" :style="countColumnStyle">{{ formatCount(item.count) }}</span>
<span class="wildcard-value-text">
<div class="wildcard-value-bar-wrapper">
<div
class="wildcard-value-bar"
:style="{ width: barWidth(item.count) }"
:class="barColorClass"
></div>
</div>
<span class="wildcard-value-text-inner">{{ item.value || "(empty)" }}</span>
</span>
<span class="wildcard-value-row-actions">
<OButton
variant="ghost"
size="icon"
:data-test="`wildcard-value-include-${i}`"
@click.stop="$emit('filter-value', item.value, 'include')"
>
<OIcon name="add" size="xs" />
</OButton>
<OButton
variant="ghost"
size="icon"
:data-test="`wildcard-value-exclude-${i}`"
@click.stop="$emit('filter-value', item.value, 'exclude')"
>
<OIcon name="remove" size="xs" />
</OButton>
</span>
<!-- Value name + count -->
<div class="wcp__row-top">
<span class="wcp__row-value">{{ item.value || "(empty)" }}</span>
<span class="wcp__row-count">{{ item.count.toLocaleString() }}</span>
</div>
<!-- Full-width progress bar -->
<div class="wcp__bar-track">
<div
class="wcp__bar-fill"
:class="barColorClass"
:style="{ width: barWidth(item.count) }"
/>
</div>
<!-- Percentage -->
<div class="wcp__row-pct">
{{ totalOccurrences > 0 ? ((item.count / totalOccurrences) * 100).toFixed(1) + '%' : '' }}
</div>
</div>
<div
v-if="displayValues.length === 0"
class="tw:text-[0.6875rem] tw:opacity-50 tw:py-4 tw:text-center"
class="wcp__empty"
>
{{ t("search.patternNoValuesAvailable") }}
</div>
</div>
<!-- ── Footer ── -->
<div v-if="displayValues.length > 0 && totalOccurrences > 0" class="wcp__footer">
<span class="wcp__occurrences">{{ totalOccurrences.toLocaleString() }} occurrences</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, nextTick } from "vue";
import { computed, ref, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { formatCount } from "@/utils/logs/convertLogData";
import { wildcardChipColor, wildcardLabel } from "@/composables/useLogs/useTemplateTokenizer";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import type { WildcardDisplayValue } from "./useWildcardHover";
const props = defineProps<{
@@ -104,53 +106,36 @@ const { t } = useI18n();
const popoverRef = ref<HTMLElement | null>(null);
const flipUpward = ref(false);
const tokenType = computed(() => wildcardLabel(props.token, props.displayValues));
const totalOccurrences = computed(() =>
props.displayValues.reduce((sum, v) => sum + v.count, 0),
);
const maxCount = computed(() =>
Math.max(...props.displayValues.map((v) => v.count), 1),
);
const countColumnStyle = computed(() => {
const chars = formatCount(maxCount.value).length;
return { width: `${chars * 0.5 + 0.125}rem` };
});
const barWidth = (count: number): string => {
if (!count) return "0%";
const pct = (count / maxCount.value) * 100;
return `${Math.max(pct, 2)}%`;
return `${Math.max((count / maxCount.value) * 100, 2)}%`;
};
const barColorClass = computed(() => {
const cls = headerClass.value;
if (cls.includes("blue")) return "wildcard-bar-blue";
if (cls.includes("green")) return "wildcard-bar-green";
if (cls.includes("orange")) return "wildcard-bar-orange";
if (cls.includes("purple")) return "wildcard-bar-purple";
if (cls.includes("grey")) return "wildcard-bar-grey";
return "wildcard-bar-default";
const cls = wildcardChipColor(props.token, props.displayValues);
if (cls.includes("blue")) return "wcp__bar--blue";
if (cls.includes("green")) return "wcp__bar--green";
if (cls.includes("orange")) return "wcp__bar--orange";
if (cls.includes("purple")) return "wcp__bar--purple";
return "wcp__bar--default";
});
const headerClass = computed(() => {
const chipClass = wildcardChipColor(props.token, props.displayValues);
const bgMatch = chipClass.match(/bg-(\S+)/);
if (bgMatch) {
return `wildcard-header-${bgMatch[1]}`;
}
return "wildcard-header-default";
});
const popoverPositionClass = computed(() => ({
"wildcard-popover--flip-up": flipUpward.value,
}));
// ── Positioning ──────────────────────────────────────────────────────────────
const popoverStyle = computed(() => {
if (!props.anchorEl) return { display: "none" };
const rect = props.anchorEl.getBoundingClientRect();
const gap = 4;
const gap = 6;
const left = Math.max(8, Math.min(rect.left, window.innerWidth - 320));
// When flipped upward, use `bottom` so the popover extends UP from the
// anchor rather than down past the viewport. When below, use `top` so it
// extends DOWN from the anchor.
if (flipUpward.value) {
return {
position: "fixed" as const,
@@ -170,39 +155,24 @@ const popoverStyle = computed(() => {
function repositionPopover() {
void nextTick().then(() => {
if (!popoverRef.value || !props.anchorEl) return;
const popoverRect = popoverRef.value.getBoundingClientRect();
// If popover overflows bottom while positioned below, flip upward
if (!flipUpward.value && popoverRect.bottom > window.innerHeight - 8) {
flipUpward.value = true;
}
// If popover overflows top while positioned above, revert to below
if (flipUpward.value && popoverRect.top < 8) {
flipUpward.value = false;
}
// Horizontal overflow — pin to right edge with 8 px margin
if (popoverRect.right > window.innerWidth - 8) {
popoverRef.value.style.left = `${window.innerWidth - popoverRect.width - 8}px`;
const r = popoverRef.value.getBoundingClientRect();
if (!flipUpward.value && r.bottom > window.innerHeight - 8) flipUpward.value = true;
if (flipUpward.value && r.top < 8) flipUpward.value = false;
if (r.right > window.innerWidth - 8) {
popoverRef.value.style.left = `${window.innerWidth - r.width - 8}px`;
}
});
}
onMounted(repositionPopover);
watch(
() => props.visible,
(isVisible) => {
if (isVisible && props.anchorEl) {
// Pre-decide direction before the popover renders so it never
// appears off-screen. Use a conservative height estimate (200 px —
// header + a handful of value rows). repositionPopover will correct
// if the actual popover height is very different.
const rect = props.anchorEl.getBoundingClientRect();
const estimatedHeight = 200;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const estimatedHeight = 260;
flipUpward.value =
spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
window.innerHeight - rect.bottom < estimatedHeight &&
rect.top > window.innerHeight - rect.bottom;
repositionPopover();
}
},
@@ -210,182 +180,167 @@ watch(
</script>
<style scoped lang="scss">
.wildcard-popover {
min-width: 14rem;
max-width: 24rem;
/* ── Container ── */
.wcp {
width: 18rem;
background: var(--o2-card-bg-solid, #ffffff);
border: 1px solid var(--o2-border, #e5e7eb);
border-radius: 0.5rem;
border-radius: 0.625rem;
box-shadow:
0 0.5rem 1.5rem rgba(0, 0, 0, 0.18),
0 0.125rem 0.375rem rgba(0, 0, 0, 0.08);
0 0.5rem 1.5rem rgba(0, 0, 0, 0.14),
0 0.125rem 0.375rem rgba(0, 0, 0, 0.06);
overflow: hidden;
animation: wildcardPopoverIn 0.15s ease-out;
animation: wcpIn 0.15s ease-out;
}
.wildcard-popover--flip-up {
animation-name: wildcardPopoverInUp;
}
.wcp--flip-up { animation-name: wcpInUp; }
@keyframes wildcardPopoverIn {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes wildcardPopoverInUp {
from {
opacity: 0;
transform: translateY(0.25rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes wcpIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
@keyframes wcpInUp { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* ── Header ── */
.wildcard-popover-header {
.wcp__header {
display: flex;
align-items: center;
padding: 0.375rem 0.75rem;
border-bottom: 1px solid var(--o2-border, #e5e7eb);
&.wildcard-header-blue-2 {
background: var(--o2-wildcard-header-blue-bg);
color: var(--o2-wildcard-header-blue-text);
}
&.wildcard-header-green-2 {
background: var(--o2-wildcard-header-green-bg);
color: var(--o2-wildcard-header-green-text);
}
&.wildcard-header-orange-2 {
background: var(--o2-wildcard-header-orange-bg);
color: var(--o2-wildcard-header-orange-text);
}
&.wildcard-header-purple-2 {
background: var(--o2-wildcard-header-purple-bg);
color: var(--o2-wildcard-header-purple-text);
}
&.wildcard-header-grey-3 {
background: var(--o2-wildcard-header-grey-bg);
color: var(--o2-wildcard-header-grey-text);
}
&.wildcard-header-default {
background: var(--o2-surface-2, #f9fafb);
color: var(--o2-text-primary, #111827);
}
justify-content: space-between;
gap: 0.5rem;
padding: 0.625rem 0.75rem 0.375rem;
}
.wildcard-popover-header-token {
.wcp__header-left {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: 0;
}
.wcp__type-badge {
flex-shrink: 0;
font-family: monospace;
font-weight: bold;
font-size: 0.75rem;
line-height: 1.4;
font-weight: 700;
}
.wcp__title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--o2-text-heading);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wcp__header-right {
display: flex;
align-items: baseline;
gap: 0.25rem;
flex-shrink: 0;
}
.wcp__unique-count {
font-size: 0.875rem;
font-weight: 700;
color: var(--o2-text-heading);
font-variant-numeric: tabular-nums;
}
.wcp__unique-label {
font-size: 0.6875rem;
color: var(--o2-text-caption);
}
/* ── Body ── */
.wildcard-popover-body {
.wcp__body {
padding: 0.25rem 0;
max-height: 20rem;
overflow-y: auto;
}
/* ── Value rows with bar chart ── */
.wildcard-value-row {
/* ── Value row ── */
.wcp__row {
padding: 0.375rem 0.75rem 0.25rem;
}
.wcp__row + .wcp__row {
padding-top: 0.5rem;
}
.wcp__row-top {
display: flex;
align-items: center;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
padding: 0.3125rem 0.75rem;
position: relative;
margin-bottom: 0.25rem;
}
.wildcard-value-count {
position: relative;
z-index: 1;
flex-shrink: 0;
text-align: right;
.wcp__row-value {
font-size: 0.75rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--o2-text-primary, #111827);
line-height: 1.4;
}
.wildcard-value-text {
position: relative;
z-index: 1;
flex: 1;
min-width: 0;
font-weight: 600;
font-family: monospace;
font-size: 0.75rem;
color: var(--o2-text-primary, #111827);
color: var(--o2-text-body);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.wildcard-value-text-inner {
position: relative;
z-index: 1;
}
.wildcard-value-row-actions {
position: relative;
z-index: 1;
display: flex;
gap: 0.125rem;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.1s ease;
}
.wildcard-value-row:hover .wildcard-value-row-actions {
opacity: 1;
}
.wildcard-value-bar-wrapper {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.wildcard-value-bar {
height: 100%;
opacity: 0.18;
flex: 1;
min-width: 0;
transition: opacity 0.1s ease;
.wildcard-value-row:hover & {
opacity: 0.28;
}
&.wildcard-bar-blue {
background: var(--o2-wildcard-bar-blue);
}
&.wildcard-bar-green {
background: var(--o2-wildcard-bar-green);
}
&.wildcard-bar-orange {
background: var(--o2-wildcard-bar-orange);
}
&.wildcard-bar-purple {
background: var(--o2-wildcard-bar-purple);
}
&.wildcard-bar-grey {
background: var(--o2-wildcard-bar-grey);
}
&.wildcard-bar-default {
background: var(--o2-text-muted, #6b7280);
}
}
.wcp__row-count {
font-size: 0.8125rem;
font-weight: 700;
color: var(--o2-text-heading);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.wcp__bar-track {
width: 100%;
height: 0.25rem;
background: var(--o2-border-color);
border-radius: 9999px;
overflow: hidden;
margin-bottom: 0.1875rem;
}
.wcp__bar-fill {
height: 100%;
border-radius: 9999px;
transition: width 0.2s ease;
&.wcp__bar--blue { background: var(--o2-wildcard-bar-blue, #6366f1); }
&.wcp__bar--green { background: var(--o2-wildcard-bar-green, #10b981); }
&.wcp__bar--orange { background: var(--o2-wildcard-bar-orange, #f59e0b); }
&.wcp__bar--purple { background: var(--o2-wildcard-bar-purple, #8b5cf6); }
&.wcp__bar--default { background: var(--o2-primary-color, #6366f1); }
}
.wcp__row-pct {
font-size: 0.625rem;
color: var(--o2-text-caption);
font-variant-numeric: tabular-nums;
}
/* ── Empty ── */
.wcp__empty {
padding: 1.5rem 0.75rem;
text-align: center;
font-size: 0.6875rem;
color: var(--o2-text-muted);
}
/* ── Footer ── */
.wcp__footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.4375rem 0.75rem;
border-top: 1px solid var(--o2-border-color);
}
.wcp__occurrences {
font-size: 0.6875rem;
font-weight: 600;
color: var(--o2-text-caption);
font-variant-numeric: tabular-nums;
}
</style>
@@ -25,6 +25,11 @@ describe("useWildcardHover", () => {
});
afterEach(() => {
// Cancel any pending show, then fire the hide so module-level hoveredToken
// is null for the next test.
const { onMouseLeave } = useWildcardHover();
onMouseLeave();
vi.advanceTimersByTime(200);
vi.useRealTimers();
});
@@ -34,6 +39,7 @@ describe("useWildcardHover", () => {
const el = document.createElement("span");
onMouseEnter("<*>", ["value1", "value2"], { currentTarget: el } as any);
vi.advanceTimersByTime(350); // wait for SHOW_DELAY_MS
expect(hoveredToken.value).not.toBeNull();
expect(hoveredToken.value!.token).toBe("<*>");
@@ -51,6 +57,7 @@ describe("useWildcardHover", () => {
onMouseEnter("<:IP>", [{ value: "10.0.0.1", count: 42 }], {
currentTarget: el,
} as any);
vi.advanceTimersByTime(350); // wait for SHOW_DELAY_MS
expect(hoveredToken.value!.displayValues).toEqual([
{ value: "10.0.0.1", count: 42 },
@@ -61,9 +68,15 @@ describe("useWildcardHover", () => {
const { onMouseEnter, onMouseLeave, hoveredToken } = useWildcardHover();
const el = document.createElement("span");
onMouseLeave();
// Timeout is now pending — onMouseEnter should cancel it
// First establish a visible token
onMouseEnter("<*>", [], { currentTarget: el } as any);
vi.advanceTimersByTime(350);
expect(hoveredToken.value).not.toBeNull();
// Leave starts a 200ms hide timer; re-enter should cancel it
onMouseLeave();
onMouseEnter("<*>", [], { currentTarget: el } as any);
vi.advanceTimersByTime(350); // new show fires
expect(hoveredToken.value).not.toBeNull();
});
@@ -75,6 +88,7 @@ describe("useWildcardHover", () => {
const el = document.createElement("span");
onMouseEnter("<*>", [], { currentTarget: el } as any);
vi.advanceTimersByTime(350); // wait for show
expect(hoveredToken.value).not.toBeNull();
onMouseLeave();
@@ -92,6 +106,7 @@ describe("useWildcardHover", () => {
const el = document.createElement("span");
onMouseEnter("<*>", [], { currentTarget: el } as any);
vi.advanceTimersByTime(350); // wait for show
onMouseLeave(); // starts 200ms timer
onPopoverEnter(); // cancels the timer
@@ -107,6 +122,7 @@ describe("useWildcardHover", () => {
const el = document.createElement("span");
onMouseEnter("<*>", [], { currentTarget: el } as any);
vi.advanceTimersByTime(350); // wait for show
onPopoverLeave();
vi.advanceTimersByTime(200);
@@ -124,6 +140,7 @@ describe("useWildcardHover", () => {
onMouseEnter("<:IP>", [], { currentTarget: el } as any);
onMouseLeave();
onMouseEnter("<:NUM>", [], { currentTarget: el } as any);
vi.advanceTimersByTime(350); // wait for last show to fire
expect(hoveredToken.value!.token).toBe("<:NUM>");
});
@@ -138,7 +155,8 @@ describe("useWildcardHover", () => {
onMouseEnter("<:IP>", [], { currentTarget: el } as any);
onMouseLeave();
onMouseEnter("<:NUM>", [], { currentTarget: el } as any);
onMouseLeave();
vi.advanceTimersByTime(350); // let last show fire
onMouseLeave(); // final leave
vi.advanceTimersByTime(200);
expect(hoveredToken.value).toBeNull();
@@ -13,14 +13,15 @@ interface HoveredToken {
anchorEl: HTMLElement | null;
}
// Module-level singleton state — there is only ever one popover visible at a
// time, shared across PatternCard (list rows) and PatternDetailsDialog (detail
// view). Each consumer calls useWildcardHover() in setup which reuses the same
// hoveredToken ref and hideTimeout. This ensures hovering a chip in one view
// hides a popover that was open in the other.
// Module-level singleton — one popover visible at a time, shared across
// PatternCard and PatternDetailsDialog.
const hoveredToken = ref<HoveredToken | null>(null);
let showTimeout: ReturnType<typeof setTimeout> | null = null;
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
const SHOW_DELAY_MS = 350; // wait until pointer settles before opening
const HIDE_DELAY_MS = 200; // grace period to move into the popover
function normalizeSampleValues(raw: any[]): WildcardDisplayValue[] {
return raw.map((item) => {
if (typeof item === "string") {
@@ -39,21 +40,37 @@ export default function useWildcardHover() {
sampleValues: any[],
event: MouseEvent,
) {
// Cancel any pending hide
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
hoveredToken.value = {
token,
displayValues: normalizeSampleValues(sampleValues),
anchorEl: event.currentTarget as HTMLElement,
};
// Cancel any previous pending show (pointer moved to a different chip)
if (showTimeout) {
clearTimeout(showTimeout);
showTimeout = null;
}
const anchorEl = event.currentTarget as HTMLElement;
// Only open after the pointer has settled for SHOW_DELAY_MS
showTimeout = setTimeout(() => {
hoveredToken.value = {
token,
displayValues: normalizeSampleValues(sampleValues),
anchorEl,
};
showTimeout = null;
}, SHOW_DELAY_MS);
}
function onMouseLeave() {
// Cancel pending show — pointer left before the delay fired
if (showTimeout) {
clearTimeout(showTimeout);
showTimeout = null;
}
hideTimeout = setTimeout(() => {
hoveredToken.value = null;
}, 200);
}, HIDE_DELAY_MS);
}
function onPopoverEnter() {
@@ -66,7 +83,7 @@ export default function useWildcardHover() {
function onPopoverLeave() {
hideTimeout = setTimeout(() => {
hoveredToken.value = null;
}, 200);
}, HIDE_DELAY_MS);
}
return {
+3 -1
View File
@@ -27,14 +27,16 @@ const patterns = {
org_identifier,
stream_name,
query,
signal,
}: {
org_identifier: string;
stream_name: string;
query: any;
signal?: AbortSignal;
}) => {
const url = `/api/${org_identifier}/streams/${stream_name}/patterns/extract`;
return http().post(url, query);
return signal ? http().post(url, query, { signal }) : http().post(url, query);
},
};
+2 -2
View File
@@ -377,7 +377,7 @@ $json-object-dark: #D1D5DB; // Light gray
--o2-log-table-row-bg: #ffffff;
--o2-log-table-row-alt-bg: #f7f8fa;
--o2-log-table-row-hover: #eff1fd;
--o2-log-table-row-border: #edeff3;
--o2-log-table-row-border: #e4e6ec;
--o2-log-table-header-bg: #f1f3f7;
--o2-log-table-text: #27282e;
@@ -625,7 +625,7 @@ $json-object-dark: #D1D5DB; // Light gray
--o2-log-table-row-bg: #21242c;
--o2-log-table-row-alt-bg: #252934;
--o2-log-table-row-hover: #3a4a78;
--o2-log-table-row-border: rgba(0, 0, 0, 0.42);
--o2-log-table-row-border: rgba(255, 255, 255, 0.14);
--o2-log-table-header-bg: var(--color-table-header-bg);
--o2-log-table-text: var(--o2-text-heading);
+107 -2
View File
@@ -99,7 +99,6 @@
background-color: var(--o2-hover-accent) !important;
}
.logs-search-bar-component {
padding-bottom: 0.0625rem; // 1px
height: 100%;
overflow: visible;
@@ -363,7 +362,7 @@
}
.query-editor-container {
height: calc(100% - 3.31rem) !important; // 35px
height: calc(100% - 2.9rem) !important; // 35px
}
.logs-auto-refresh-interval {
@@ -1023,6 +1022,7 @@
// Dynamic height controlled by CSS classes
&--visible {
height: 6.25rem; // 100px
padding-top: 0.25rem; // 4px breathing room below the result-bar separator
opacity: 1;
transition: all 0.3s ease-in-out;
}
@@ -1055,6 +1055,111 @@
left: 50%;
}
.histogram-skeleton {
// Scoped tokens — override per theme so shimmer always has contrast
--hsk-bar: var(--color-grey-100); // #f5f5f5 light — very light skeleton bars
--hsk-shimmer: rgba(255, 255, 255, 0.65);
.body--dark & {
--hsk-bar: var(--color-grey-700); // #404040 — subtle on dark bg
--hsk-shimmer: rgba(255, 255, 255, 0.06);
}
height: 6.25rem;
display: flex;
flex-direction: column;
padding-top: 0.25rem;
overflow: hidden;
&__main {
flex: 1;
display: flex;
min-height: 0;
}
&__y-axis {
width: 2.25rem;
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
padding-right: 0.3125rem;
padding-bottom: 2px;
}
&__y-label {
height: 0.4375rem;
border-radius: 0.125rem;
background-color: var(--hsk-bar);
}
&__plot {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
border-left: 1px solid var(--o2-border-color);
border-bottom: 1px solid var(--o2-border-color);
}
&__bars {
flex: 1;
display: flex;
align-items: flex-end;
gap: 2px;
padding: 0.25rem 0.25rem 0;
overflow: hidden;
position: relative;
// Single shimmer beam that sweeps left → right across all bars at once
&::after {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent 0%,
transparent 20%,
var(--hsk-shimmer) 50%,
transparent 80%,
transparent 100%
);
animation: histogram-bar-shimmer 1.6s ease-in-out infinite;
pointer-events: none;
}
}
&__bar {
flex: 0 0 7px;
flex-shrink: 0;
border-radius: 1px 1px 0 0;
background-color: var(--hsk-bar);
}
&__x-axis {
display: flex;
justify-content: space-between;
padding-left: 2.25rem;
padding-top: 0.1875rem;
}
&__x-label {
width: 2.25rem;
height: 0.4375rem;
border-radius: 0.125rem;
background-color: var(--color-skeleton-base);
}
}
@keyframes histogram-bar-shimmer {
from { left: -100%; }
to { left: 100%; }
}
.histogram-error {
margin: 0.5rem 0; // 8px (reduced from 30px)
border-radius: 0.5rem;
+4 -79
View File
@@ -2,91 +2,16 @@
width: 170px;
}
.analyze-button {
font-size: 0.75rem;
font-weight: 600;
padding: 0 0.75rem;
transition: all 0.2s;
margin-left: 2px;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
.inspect-button{
margin-left: 0.5rem;
margin-right: 0.5rem;
// Result info bar — same background as query editor footer
.result-bar {
background: var(--o2-section-header-bg);
border-bottom: 1px solid var(--o2-border-color);
}
.pagination-block {
.select-pagination {
position: relative;
}
.toolbar-toggle-container {
display: flex;
align-items: center;
justify-content: center;
padding: 0 0.175rem;
border: 0.0625rem solid var(--o2-border-color);
border-radius: 0.375rem;
transition: all 0.2s ease;
cursor: pointer;
height: 32px;
&:hover {
background-color: var(--o2-hover-accent);
}
:deep(.q-toggle__inner) {
padding: 0;
}
.toolbar-icon-in-toggle {
font-size: 0.9rem;
}
}
.wrap-content-btn {
height: 1.5rem !important;
min-height: 1.5rem !important;
width: 1.45rem !important; // Square button
padding: 0 !important;
border: 0.0626rem solid var(--o2-border-color) !important;
border-radius: 0.25rem !important; // 4px - match select-pagination
transition: all 0.2s ease;
margin: 0; // flex parent handles alignment
background: rgba(255, 255, 255, 0.1) !important; // Glassmorphic
backdrop-filter: blur(10px) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
&:hover {
background: rgba(255, 255, 255, 0.15) !important;
}
&--active {
background: var(--q-primary) !important;
color: white;
&:hover {
background: var(--q-primary) !important;
opacity: 0.85;
}
}
:deep(.q-icon) {
font-size: 0.75rem !important; // Match select-pagination icon size
}
:deep(.q-btn__content) {
min-height: 1.45rem !important;
padding: 0 !important;
}
}
}
.search-list {
+15 -2
View File
@@ -469,16 +469,29 @@ describe("convertLogData.ts", () => {
expect(options.series[0].data[1][1]).toBe(0);
});
it("shows legend at the bottom", () => {
it("hides legend when there is only one series", () => {
const bd = makeBreakdown([["info", [1]]]);
const { options } = convertStackedLogData([ts1], bd, baseParams, false);
expect(options.legend.show).toBe(false);
expect(options.legend.bottom).toBe(0);
});
it("shows legend at the bottom when there are multiple series", () => {
const bd = makeBreakdown([["info", [1]], ["error", [2]]]);
const { options } = convertStackedLogData([ts1], bd, baseParams, false);
expect(options.legend.show).toBe(true);
expect(options.legend.bottom).toBe(0);
});
it("sets grid bottom to 20 to accommodate legend", () => {
it("sets grid bottom to 5 for a single series (no legend)", () => {
const bd = makeBreakdown([["info", [1]]]);
const { options } = convertStackedLogData([ts1], bd, baseParams, false);
expect(options.grid.bottom).toBe("5");
});
it("sets grid bottom to 20 to accommodate legend when multiple series", () => {
const bd = makeBreakdown([["info", [1]], ["error", [2]]]);
const { options } = convertStackedLogData([ts1], bd, baseParams, false);
expect(options.grid.bottom).toBe("20");
});
+2 -2
View File
@@ -269,7 +269,7 @@ export const convertStackedLogData = (
left: "20",
right: "20",
top: "5",
bottom: "20",
bottom: series.length > 1 ? "20" : "5",
},
tooltip: {
show: true,
@@ -310,7 +310,7 @@ export const convertStackedLogData = (
},
},
legend: {
show: true,
show: series.length > 1,
bottom: 0,
type: "scroll",
textStyle: { fontSize: 11 },
File diff suppressed because one or more lines are too long