mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-03 16:10:21 +00:00
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:
co-authored by
github-actions[bot]
Shrinath Rao
Claude Opus 4.7
parent
5eb63395f1
commit
61aebff552
@@ -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);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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)]',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -404,7 +404,18 @@
|
||||
"selectLogStream": "ログストリームを選択",
|
||||
"noData": "データはありません",
|
||||
"functionEditorLabel": "関数エディター",
|
||||
"loadingStream": "ストリームをロード中..."
|
||||
"loadingStream": "ストリームをロード中...",
|
||||
"hideHistogram": "ヒストグラムを非表示",
|
||||
"disableQuickMode": "クイックモードを無効にする",
|
||||
"menuGroupSetOnce": "1 回設定",
|
||||
"menuGroupSavedViews": "保存済みビュー",
|
||||
"menuGroupTools": "ツール",
|
||||
"menuGroupHistory": "歴史",
|
||||
"menuGroupDownloads": "ダウンロード",
|
||||
"menuGroupSchedule": "スケジュール",
|
||||
"menuGroupInspect": "検査",
|
||||
"validSql": "有効な SQL",
|
||||
"invalidSql": "クエリが無効です"
|
||||
},
|
||||
"menu": {
|
||||
"home": "ホーム",
|
||||
|
||||
@@ -404,7 +404,18 @@
|
||||
"selectLogStream": "로그 스트림 선택",
|
||||
"noData": "사용 가능한 데이터 없음",
|
||||
"functionEditorLabel": "함수 에디터",
|
||||
"loadingStream": "스트림 로드 중..."
|
||||
"loadingStream": "스트림 로드 중...",
|
||||
"hideHistogram": "히스토그램 숨기기",
|
||||
"disableQuickMode": "퀵 모드 비활성화",
|
||||
"menuGroupSetOnce": "한 번 설정",
|
||||
"menuGroupSavedViews": "저장된 뷰",
|
||||
"menuGroupTools": "도구",
|
||||
"menuGroupHistory": "역사",
|
||||
"menuGroupDownloads": "다운로드",
|
||||
"menuGroupSchedule": "스케줄",
|
||||
"menuGroupInspect": "검사",
|
||||
"validSql": "유효한 SQL",
|
||||
"invalidSql": "잘못된 쿼리"
|
||||
},
|
||||
"menu": {
|
||||
"home": "홈",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -404,7 +404,18 @@
|
||||
"selectLogStream": "选择日志流",
|
||||
"noData": "无可用数据",
|
||||
"functionEditorLabel": "函数编辑器",
|
||||
"loadingStream": "正在加载直播..."
|
||||
"loadingStream": "正在加载直播...",
|
||||
"hideHistogram": "隐藏直方图",
|
||||
"disableQuickMode": "禁用快速模式",
|
||||
"menuGroupSetOnce": "设置一次",
|
||||
"menuGroupSavedViews": "保存的视图",
|
||||
"menuGroupTools": "工具",
|
||||
"menuGroupHistory": "历史",
|
||||
"menuGroupDownloads": "下载",
|
||||
"menuGroupSchedule": "日程安排",
|
||||
"menuGroupInspect": "检查",
|
||||
"validSql": "有效的 SQL",
|
||||
"invalidSql": "查询无效"
|
||||
},
|
||||
"menu": {
|
||||
"home": "主页",
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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";
|
||||
|
||||
+522
-315
File diff suppressed because it is too large
Load Diff
@@ -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", () => {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user