diff --git a/tests/ui-testing/pages/logsPages/unflattened.js b/tests/ui-testing/pages/logsPages/unflattened.js index eb83ccd2fe..87a7f94aa1 100644 --- a/tests/ui-testing/pages/logsPages/unflattened.js +++ b/tests/ui-testing/pages/logsPages/unflattened.js @@ -243,6 +243,7 @@ class UnflattenedPage { await this.page .locator('[data-test="logs-search-result-detail-dialog"][data-state="open"]') .waitFor({ state: 'visible', timeout: 10000 }); + await this.openJsonDetailTab(); // JSON content renders asynchronously after the drawer opens. Wait for the // first detail key to actually render instead of a fixed 3s sleep — the keys // appearing IS the signal callers need before probing for _o2_id, and the @@ -254,6 +255,33 @@ class UnflattenedPage { .catch(() => {}); } + /** + * Select the JSON tab in the log detail drawer. + * + * The drawer now opens on the Table tab — see 4194865a63 "logs sidebar table + * will be default view and draggable tabs", which flipped + * SearchResult.vue's `detailTableInitialTab` and DetailTable's `initialTab` + * default from "json" to "table". Everything this page object reads + * (`_o2_id`, the per-key `log-expand-detail-key-*` rows, the unflattened + * sub-tab) lives in the JSON panel, and `OTabPanels` is used with + * `keep-alive`, so the inactive JSON panel stays mounted but `v-show`-hidden. + * It is therefore present in the DOM yet never *visible*, which is what made + * every `_o2_id` probe time out. + * + * The tab bar is user-reorderable (order persisted in localStorage), so + * select by data-test rather than position. + */ + async openJsonDetailTab() { + const jsonTab = this.page + .locator('[data-test="logs-search-result-detail-dialog"] [data-test="log-detail-json-tab"]') + .first(); + await jsonTab.waitFor({ state: 'visible', timeout: 10000 }); + if ((await jsonTab.getAttribute('data-state')) !== 'active') { + await jsonTab.click(); + } + await this.logDetailJsonContent.waitFor({ state: 'visible', timeout: 10000 }); + } + /** * Close the log detail drawer if it is open. Uses Escape as the fallback * when the close button is intercepted by an overlay. @@ -276,9 +304,18 @@ class UnflattenedPage { * drawer OPEN on the matching row so the caller can continue interacting * with the `_o2_id` element. Returns -1 if no row in the range contains * `_o2_id`. + * + * Pass `deadlineAt` (an absolute `Date.now()` value) to cap how long the + * sweep may run. Each row costs a drawer open plus a probe, so a 15-row + * sweep on a slow runner could otherwise outlive the whole test timeout and + * get the test killed mid-action instead of returning -1. */ - async findRowWithO2Id(maxRows = 5) { + async findRowWithO2Id(maxRows = 5, { deadlineAt = null } = {}) { for (let rowIndex = 0; rowIndex < maxRows; rowIndex++) { + if (deadlineAt && Date.now() >= deadlineAt) { + console.warn(`findRowWithO2Id: time budget reached after scanning ${rowIndex} row(s)`); + break; + } const expandBtn = this.page.locator( `[data-test="log-table-column-${rowIndex}-_timestamp"] [data-test="table-row-expand-menu"]` ); diff --git a/tests/ui-testing/playwright-tests/Logs/unflattened.spec.js b/tests/ui-testing/playwright-tests/Logs/unflattened.spec.js index 3943b8519b..aef8d7b6ad 100644 --- a/tests/ui-testing/playwright-tests/Logs/unflattened.spec.js +++ b/tests/ui-testing/playwright-tests/Logs/unflattened.spec.js @@ -1,5 +1,4 @@ const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js'); -const logData = require("../../fixtures/log.json"); const PageManager = require('../../pages/page-manager.js'); const testLogger = require('../utils/test-logger.js'); const { getOrgIdentifier, isCloudEnvironment } = require('../utils/cloud-auth.js'); @@ -10,21 +9,53 @@ const { ingestTestData: ingestion } = require('../utils/data-ingestion.js'); test.describe("Unflattened testcases", () => { let pageManager; - // let logData; function removeUTFCharacters(text) { // Remove UTF characters using regular expression return text.replace(/[^\x00-\x7F]/g, " "); } + // ── Time budget ─────────────────────────────────────────────────────────── + // beforeEach + body + afterEach all share ONE Playwright timeout. The waits + // below used to be sized in isolation and optimistically — a 90s + 120s + // readiness gate, then up to 3 scan attempts each fronted by a fresh + // navigation and a 60s + 120s query wait — so their sum could exceed the + // whole budget several times over. When it did, Playwright killed the test in + // the middle of whatever it was doing and reported + // "page.goto: Target page, context or browser has been closed" — the symptom + // of the kill, not the cause. Every long wait is now clamped to the time + // actually left, so a slow backend produces a diagnostic failure while the + // browser is still alive, and the honest path has room to finish. + let testStartedAt = 0; + + // Held back so each test's "toggle Store Original Data back OFF" cleanup can + // always run; leaving the stream with the flag ON leaks into the next test in + // this serial file. + const CLEANUP_RESERVE_MS = 45000; + + // A scan attempt costs a row sweep plus, on a miss, a fresh navigation and a + // full re-query. Only start another round when at least this much is left. + const SCAN_ATTEMPT_COST_MS = 90000; + + function msLeft(reserve = CLEANUP_RESERVE_MS) { + const budget = test.info().timeout; + if (!budget) return Number.MAX_SAFE_INTEGER; // timeout disabled + return budget - (Date.now() - testStartedAt) - reserve; + } + + function clampToBudget(preferred, { min = 10000, reserve = CLEANUP_RESERVE_MS } = {}) { + return Math.max(min, Math.min(preferred, msLeft(reserve))); + } + async function applyQueryButton(page) { // runQueryAndWaitForResults already handles an in-flight auto-search internally: // it waits for the button to exit the "Cancel" state before clicking, and if the // state never clears it cancels and re-clicks Run itself. The old double-click // wrapper (3s sleep + run + 1s sleep + run) duplicated that logic and added ~5s // of fixed sleep to every call. - await pageManager.logsPage.runQueryAndWaitForResults(); + await pageManager.logsPage.runQueryAndWaitForResults(clampToBudget(60000, { min: 20000 })); - // 2-minute timeout: cloud re-indexing after Store Original Data changes takes longer than the default 30s - await pageManager.logsPage.waitForSearchResults(120000); + // Up to 2 minutes: cloud re-indexing after Store Original Data changes takes + // longer than the default 30s — but never longer than the budget left. + await pageManager.logsPage.waitForSearchResults(clampToBudget(120000, { min: 20000 })); } // Re-run the log search from a FRESH page load so the row scan below sees a @@ -38,9 +69,9 @@ test.describe("Unflattened testcases", () => { // otherwise fall back to the quick-mode interesting-fields-only query). async function runFreshLogSearch(page, { sqlSelectAll = false } = {}) { await page.goto( - `${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate` + `${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`, + { waitUntil: 'domcontentloaded', timeout: clampToBudget(45000, { min: 15000 }) } ); - await page.waitForLoadState('domcontentloaded'); if (sqlSelectAll) { await pageManager.logsPage.typeQuery('SELECT * FROM "e2e_automate" ORDER BY _timestamp DESC'); await pageManager.logsPage.waitForQueryEditorValue('ORDER BY _timestamp DESC'); @@ -48,7 +79,77 @@ test.describe("Unflattened testcases", () => { await applyQueryButton(page); } + // Backend readiness gate for _o2_id. A single budget-bounded wait: the old + // 90s-then-120s pair could consume 210s of a 300s test on its own, leaving + // nothing for the UI work it was supposed to be preparing. + async function waitForO2IdReady() { + const ready = await pageManager.unflattenedPage.waitForO2IdQueryable({ + timeout: clampToBudget(90000, { min: 20000 }), + }); + if (ready) { + testLogger.info('_o2_id confirmed queryable via search API'); + } else { + testLogger.warn('_o2_id readiness gate timed out — the UI scan below is unlikely to find it'); + } + return ready; + } + + // Scan the rendered log rows for _o2_id, leaving the detail drawer open on the + // matching row. A miss means UI/streaming lag, which only a fresh navigation + + // re-query recovers (re-clicking Run in place does not recover a stale/partial + // streaming result set), so each retry costs a full search — hence the budget + // check before starting another one. + async function scanForO2IdOrFail(page, { sqlSelectAll = false, backendReady = true, reserve = CLEANUP_RESERVE_MS } = {}) { + for (let attempt = 1; attempt <= 3; attempt++) { + const matchedRow = await pageManager.unflattenedPage.findRowWithO2Id(15, { + deadlineAt: Date.now() + clampToBudget(45000, { min: 15000, reserve }), + }); + if (matchedRow !== -1) { + testLogger.info(`Found _o2_id in row ${matchedRow} (attempt ${attempt})`); + await pageManager.unflattenedPage.o2IdText.click(); + return; + } + + testLogger.warn(`_o2_id not found in the scanned rows on attempt ${attempt}`); + await pageManager.unflattenedPage.closeLogDetailDrawerIfOpen(); + + // A fresh search only fixes UI-side staleness. If the backend never + // surfaced _o2_id, retrying the UI cannot help — it just burns the budget + // until Playwright kills the test mid-action. + if (!backendReady) break; + if (attempt === 3) break; + if (msLeft(reserve) < SCAN_ATTEMPT_COST_MS) { + testLogger.warn('Not enough test budget left for another scan attempt'); + break; + } + testLogger.info('Re-running the query from a fresh page before the next scan'); + await runFreshLogSearch(page, { sqlSelectAll }); + } + + // Diagnostic: dump what the first row actually does contain. Skipped when + // the budget is nearly gone — being killed here would replace the useful + // error below with an opaque "browser has been closed". + if (msLeft(0) > 30000) { + try { + await pageManager.unflattenedPage.openLogRowDetail(0); + const allKeys = await pageManager.unflattenedPage.allLogDetailKeys.allTextContents(); + testLogger.error('Available fields in log detail', { fields: allKeys }); + } catch (e) { + testLogger.error('Could not retrieve available fields'); + } + } + + throw new Error( + 'Failed to find _o2_id field in log details' + + (backendReady + ? '' + : ' — the search API never returned a record containing _o2_id, so "Store Original Data" had not taken effect on the backend') + + ` (${Math.round(Math.max(0, msLeft(0)) / 1000)}s of test budget left)` + ); + } + test.beforeEach(async ({ page }) => { + testStartedAt = Date.now(); pageManager = new PageManager(page); if (isCloudEnvironment()) { await navigateToBase(page); @@ -102,11 +203,14 @@ test.describe("Unflattened testcases", () => { await pageManager.unflattenedPage.closeButton.click(); await page.waitForTimeout(500); - await page.goto( - `${logData.logsUrl}?org_identifier=${getOrgIdentifier()}` - ); - await pageManager.logsPage.selectStream("e2e_automate"); - await applyQueryButton(page); + // Leave the browser on the logs page with e2e_automate selected. Two costs + // were removed here because nothing in either test consumes their result: + // - the trailing search (up to 180s of query waiting) — both tests navigate + // straight back to Streams from here; + // - the explicit goto, since selectStream navigates to the logs page itself. + // apiWaitMs=0 also skips selectStream's own stream-availability poll (up to + // 120s on cloud) — waitForStreamAvailable above already established that. + await pageManager.logsPage.selectStream("e2e_automate", 5, 0); }); test.afterEach(async ({ page }) => { @@ -153,54 +257,22 @@ test.describe("Unflattened testcases", () => { // Deterministic readiness gate: poll the search API until _o2_id is queryable // instead of sleeping a fixed interval and hoping indexing has caught up. - // This is what makes the single UI scan below succeed on the first attempt. + // This is what makes the UI scan below succeed on the first attempt. testLogger.info('Waiting for _o2_id to become queryable via search API'); - // Respect the gate's result. On heavily contended CI runners indexing can - // lag past the gate window; the gate returning false (ignored previously) - // is exactly when the subsequent UI scan was doomed. Give the backend one - // more long wait before falling through to the UI scan, so the UI step - // isn't asked to find a field the backend hasn't surfaced yet. - const o2idReady = await pageManager.unflattenedPage.waitForO2IdQueryable(); - if (o2idReady) { - testLogger.info('_o2_id confirmed queryable via search API'); - } else { - testLogger.warn('_o2_id readiness gate timed out; waiting once more before UI scan'); - const o2idReadyRetry = await pageManager.unflattenedPage.waitForO2IdQueryable({ timeout: 120000 }); - testLogger.info('_o2_id readiness gate second wait result', { ready: o2idReadyRetry }); - } + const o2idReady = await waitForO2IdReady(); // Navigate directly with stream in URL — selectStream would deselect it because // the Pinia store already has e2e_automate selected from beforeEach testLogger.info('Navigating to logs with e2e_automate stream'); - await page.goto(`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForTimeout(1000); + await page.goto( + `${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`, + { waitUntil: 'domcontentloaded', timeout: clampToBudget(45000, { min: 15000 }) } + ); await applyQueryButton(page); testLogger.info('Search query applied, logs should now contain _o2_id field'); - testLogger.info('Searching log rows for _o2_id field (iterates first 15 rows per attempt)'); - let o2idFound = false; - // The backend readiness gate above already guarantees _o2_id is queryable, so - // a miss here is UI/streaming lag — recover with a FRESH log search (reload + - // re-run) rather than re-ingesting. Re-clicking Run in place does not recover a - // stale/partial streaming result set; a fresh navigation does (see runFreshLogSearch). - for (let attempt = 1; attempt <= 3; attempt++) { - const matchedRow = await pageManager.unflattenedPage.findRowWithO2Id(15); - if (matchedRow !== -1) { - testLogger.info(`Found _o2_id in row ${matchedRow} (attempt ${attempt})`); - await pageManager.unflattenedPage.o2IdText.click(); - o2idFound = true; - break; - } - testLogger.warn(`_o2_id not found in first 15 rows on attempt ${attempt}, re-running query from a fresh page`); - await pageManager.unflattenedPage.closeLogDetailDrawerIfOpen(); - if (attempt < 3) { - await runFreshLogSearch(page); - } - } - if (!o2idFound) { - throw new Error('Failed to find _o2_id field in log details after 3 attempts'); - } + testLogger.info('Searching log rows for _o2_id field'); + await scanForO2IdOrFail(page, { backendReady: o2idReady }); testLogger.info('Switching to unflattened tab'); await pageManager.unflattenedPage.unflattenedTab.waitFor(); @@ -286,26 +358,15 @@ test.describe("Unflattened testcases", () => { // instead of a fixed 15s sleep + UI re-ingestion retry loop. This was the // dominant source of the timeout flake on contended CI runners. testLogger.info('Waiting for _o2_id to become queryable via search API'); - // Respect the gate's result. On heavily contended CI runners indexing can - // lag past the gate window; the gate returning false (ignored previously) - // is exactly when the subsequent UI scan was doomed. Give the backend one - // more long wait before falling through to the UI scan, so the UI step - // isn't asked to find a field the backend hasn't surfaced yet. - const o2idReady = await pageManager.unflattenedPage.waitForO2IdQueryable(); - if (o2idReady) { - testLogger.info('_o2_id confirmed queryable via search API'); - } else { - testLogger.warn('_o2_id readiness gate timed out; waiting once more before UI scan'); - const o2idReadyRetry = await pageManager.unflattenedPage.waitForO2IdQueryable({ timeout: 120000 }); - testLogger.info('_o2_id readiness gate second wait result', { ready: o2idReadyRetry }); - } + const o2idReady = await waitForO2IdReady(); // Navigate directly with stream in URL — selectStream would deselect it because // the Pinia store already has e2e_automate selected from beforeEach testLogger.info('Navigating to logs with e2e_automate stream'); - await page.goto(`${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`); - await page.waitForLoadState('domcontentloaded'); - await page.waitForTimeout(1000); + await page.goto( + `${process.env.ZO_BASE_URL}/web/logs?org_identifier=${getOrgIdentifier()}&stream_type=logs&stream=e2e_automate`, + { waitUntil: 'domcontentloaded', timeout: clampToBudget(45000, { min: 15000 }) } + ); testLogger.info('Ensuring Quick Mode is on'); await pageManager.logsPage.ensureQuickModeState(true); @@ -348,38 +409,12 @@ test.describe("Unflattened testcases", () => { testLogger.info('Executing SELECT * query to fetch fresh data with _o2_id'); await applyQueryButton(page); - testLogger.info('Searching log rows for _o2_id field (iterates first 15 rows per attempt)'); + testLogger.info('Searching log rows for _o2_id field'); // With ORDER BY _timestamp DESC the newest rows come first (the fixture carries - // no _timestamp, so the Store-Original-Data re-ingest is genuinely newest). The - // backend readiness gate above guarantees _o2_id is queryable, so a miss here is - // UI/streaming lag — recover with a FRESH SELECT * search (reload + re-type + - // re-run) rather than re-ingesting. Re-clicking Run in place does not recover a - // stale/partial streaming result set; a fresh navigation does. - let o2idFound = false; - for (let attempt = 1; attempt <= 3; attempt++) { - const matchedRow = await pageManager.unflattenedPage.findRowWithO2Id(15); - if (matchedRow !== -1) { - testLogger.info(`Found _o2_id in row ${matchedRow} (attempt ${attempt})`); - await pageManager.unflattenedPage.o2IdText.click(); - o2idFound = true; - break; - } - testLogger.warn(`_o2_id not found in first 15 rows on attempt ${attempt}, re-running query from a fresh page`); - if (attempt === 3) { - try { - const allKeys = await pageManager.unflattenedPage.allLogDetailKeys.allTextContents(); - testLogger.error('Available fields in log detail', { fields: allKeys }); - } catch (e) { - testLogger.error('Could not retrieve available fields'); - } - break; - } - await pageManager.unflattenedPage.closeLogDetailDrawerIfOpen(); - await runFreshLogSearch(page, { sqlSelectAll: true }); - } - if (!o2idFound) { - throw new Error('Failed to find _o2_id field in log details after 3 attempts'); - } + // no _timestamp, so the Store-Original-Data re-ingest is genuinely newest). + // Larger reserve than the default: this test's tail does a third ingestion and + // a full explorer walk after the cleanup toggle, so it needs more budget kept back. + await scanForO2IdOrFail(page, { sqlSelectAll: true, backendReady: o2idReady, reserve: 90000 }); await page.waitForTimeout(500); testLogger.info('Switching to unflattened tab'); @@ -447,7 +482,9 @@ test.describe("Unflattened testcases", () => { await page.waitForTimeout(1500); testLogger.info('Waiting for log detail panel to load'); - await pageManager.unflattenedPage.logDetailJsonContent.waitFor({ state: "visible", timeout: 10000 }); + // The drawer opens on the Table tab, so select JSON before waiting on the + // JSON panel and its per-key rows (see openJsonDetailTab). + await pageManager.unflattenedPage.openJsonDetailTab(); await page.waitForTimeout(1000); testLogger.info('Looking for timestamp dropdown in log details'); diff --git a/web/scripts/design-debt-baseline.json b/web/scripts/design-debt-baseline.json index e15cf7c210..19076b2cb0 100644 --- a/web/scripts/design-debt-baseline.json +++ b/web/scripts/design-debt-baseline.json @@ -2,7 +2,7 @@ "totals": { "rawProjectRamp": 139, "rawVarInComponent": 585, - "arbZ": 35, + "arbZ": 34, "darkMechanism": 20, "unscopedStyle": 7, "stylePxUnit": 90 @@ -382,9 +382,6 @@ "lib/navigation/Tabs/OTab.vue": { "rawProjectRamp": 1 }, - "lib/overlay/Dropdown/ODropdown.vue": { - "arbZ": 1 - }, "lib/overlay/Tooltip/OTooltip.vue": { "arbZ": 1 }, diff --git a/web/src/lib/overlay/ContextMenu/OContextMenu.spec.ts b/web/src/lib/overlay/ContextMenu/OContextMenu.spec.ts new file mode 100644 index 0000000000..9b78956f6c --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenu.spec.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi } from "vitest"; +import { h, nextTick } from "vue"; +import { mount } from "@vue/test-utils"; +import OContextMenu from "./OContextMenu.vue"; +import OContextMenuItem from "./OContextMenuItem.vue"; + +// Reka UI portals content into . Disable portal for unit tests. +vi.mock("reka-ui", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ContextMenuPortal: actual.ContextMenuContent, // render inline + }; +}); + +function mountMenu(props: Record = {}) { + return mount(OContextMenu, { + props, + slots: { + trigger: "
Right click me
", + default: { + render: () => h(OContextMenuItem, {}, { default: () => "Copy" }), + }, + }, + attachTo: document.body, + }); +} + +// ContextMenuTrigger awaits nextTick before opening, so callers need two ticks. +async function rightClick(wrapper: ReturnType) { + await wrapper.find('[data-testid="region"]').trigger("contextmenu"); + await nextTick(); + await nextTick(); +} + +describe("OContextMenu", () => { + it("renders the trigger slot as-child without a wrapper element", () => { + const wrapper = mountMenu(); + const region = wrapper.find('[data-testid="region"]'); + expect(region.exists()).toBe(true); + // as-child means the slot root IS the trigger — it carries the state attr + expect(region.attributes("data-state")).toBe("closed"); + }); + + it("stays closed until a contextmenu event fires", () => { + const wrapper = mountMenu(); + expect(wrapper.text()).not.toContain("Copy"); + }); + + it("opens on right-click and renders menu content", async () => { + const wrapper = mountMenu(); + await rightClick(wrapper); + expect(wrapper.emitted("update:open")?.[0]).toEqual([true]); + }); + + it("does not open when disabled", async () => { + const wrapper = mountMenu({ disabled: true }); + await rightClick(wrapper); + expect(wrapper.emitted("update:open")).toBeUndefined(); + }); + + it("emits update:open(false) when Escape is pressed", async () => { + const wrapper = mountMenu(); + await rightClick(wrapper); + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + await nextTick(); + await nextTick(); + const events = wrapper.emitted("update:open") ?? []; + expect(events.at(-1)).toEqual([false]); + }); + + it("closes when an ancestor scrolls, since the menu is pointer-anchored", async () => { + const wrapper = mountMenu(); + await rightClick(wrapper); + window.dispatchEvent(new Event("scroll")); + await nextTick(); + await nextTick(); + const events = wrapper.emitted("update:open") ?? []; + expect(events.at(-1)).toEqual([false]); + }); +}); diff --git a/web/src/lib/overlay/ContextMenu/OContextMenu.types.ts b/web/src/lib/overlay/ContextMenu/OContextMenu.types.ts new file mode 100644 index 0000000000..bb67606e74 --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenu.types.ts @@ -0,0 +1,46 @@ +/** + * OContextMenu.types.ts — public types for OContextMenu. + * + * OContextMenu is the right-click (and long-press on touch) counterpart to + * ODropdown. Positioning is anchored to the pointer, not to the trigger + * element, so the trigger can be an arbitrarily large region (a whole table, + * a canvas, a list) and the menu still opens exactly where the user clicked. + */ + +import type { ContextMenuRootProps } from "reka-ui"; + +// Re-export family types so callers can import everything from one place if needed +export type { + ContextMenuItemVariant, + ContextMenuItemProps, + ContextMenuItemEmits, + ContextMenuItemSlots, +} from "./OContextMenuItem.types"; +export type { ContextMenuLabelSlots } from "./OContextMenuLabel.types"; +export type { ContextMenuSeparatorSlots } from "./OContextMenuSeparator.types"; + +export interface ContextMenuProps { + /** + * Disables the menu without unmounting it — right-click falls through to the + * browser's native context menu. Use for read-only or permission-gated views. + */ + disabled?: boolean; + /** Whether the menu blocks interaction with the rest of the page (default: false) */ + modal?: ContextMenuRootProps["modal"]; + /** Extra class(es) for the content (menu) element */ + contentClass?: string; +} + +export interface ContextMenuEmits { + (e: "update:open", value: boolean): void; +} + +export interface ContextMenuSlots { + /** + * The region that responds to right-click — rendered as-child, so it keeps + * its own tag and attributes (no wrapper element is introduced). + */ + trigger?: () => unknown; + /** OContextMenuItem / OContextMenuLabel / OContextMenuSeparator children */ + default?: () => unknown; +} diff --git a/web/src/lib/overlay/ContextMenu/OContextMenu.vue b/web/src/lib/overlay/ContextMenu/OContextMenu.vue new file mode 100644 index 0000000000..d1f2c70f6f --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenu.vue @@ -0,0 +1,119 @@ + + + diff --git a/web/src/lib/overlay/ContextMenu/OContextMenuItem.spec.ts b/web/src/lib/overlay/ContextMenu/OContextMenuItem.spec.ts new file mode 100644 index 0000000000..606241de79 --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenuItem.spec.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from "vitest"; +import { h, nextTick } from "vue"; +import { mount } from "@vue/test-utils"; +import OContextMenu from "./OContextMenu.vue"; +import OContextMenuItem from "./OContextMenuItem.vue"; + +// Reka UI portals content into . Disable portal for unit tests. +vi.mock("reka-ui", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ContextMenuPortal: actual.ContextMenuContent, // render inline + }; +}); + +// OContextMenuItem requires a MenuRoot context — mount it inside an open menu. +async function mountItemInMenu( + itemProps: Record = {}, + itemSlots: Record ReturnType> = {}, +) { + const wrapper = mount(OContextMenu, { + slots: { + trigger: "
Right click me
", + default: { render: () => h(OContextMenuItem, itemProps, itemSlots) }, + }, + attachTo: document.body, + }); + await wrapper.find('[data-testid="region"]').trigger("contextmenu"); + await nextTick(); + await nextTick(); + return wrapper; +} + +describe("OContextMenuItem", () => { + it("renders slot content", async () => { + const wrapper = await mountItemInMenu( + {}, + { default: () => h("span", "Copy value") }, + ); + expect(wrapper.text()).toContain("Copy value"); + }); + + it("renders icon-left slot", async () => { + const wrapper = await mountItemInMenu( + {}, + { "icon-left": () => h("span", { "data-testid": "icon-left" }, "←") }, + ); + expect(wrapper.find('[data-testid="icon-left"]').exists()).toBe(true); + }); + + it("renders icon-right slot", async () => { + const wrapper = await mountItemInMenu( + {}, + { "icon-right": () => h("span", { "data-testid": "icon-right" }, "→") }, + ); + expect(wrapper.find('[data-testid="icon-right"]').exists()).toBe(true); + }); + + it("sets data-disabled when disabled=true", async () => { + const wrapper = await mountItemInMenu({ disabled: true }); + expect(wrapper.find("[data-disabled]").exists()).toBe(true); + }); + + it("applies default variant classes", async () => { + const wrapper = await mountItemInMenu( + {}, + { default: () => h("span", "Action") }, + ); + expect(wrapper.find(".text-dropdown-item-text").exists()).toBe(true); + }); + + it("applies destructive variant classes", async () => { + const wrapper = await mountItemInMenu( + { variant: "destructive" }, + { default: () => h("span", "Delete") }, + ); + expect(wrapper.find(".text-dropdown-item-destructive-text").exists()).toBe( + true, + ); + }); + + it("emits select when clicked", async () => { + const wrapper = await mountItemInMenu( + {}, + { default: () => h("span", "Copy") }, + ); + const item = wrapper.findComponent(OContextMenuItem); + await item.trigger("click"); + expect(item.emitted("select")).toBeTruthy(); + }); + + it("does not emit select when disabled", async () => { + const wrapper = await mountItemInMenu( + { disabled: true }, + { default: () => h("span", "Copy") }, + ); + const item = wrapper.findComponent(OContextMenuItem); + await item.trigger("click"); + expect(item.emitted("select")).toBeFalsy(); + }); +}); diff --git a/web/src/lib/overlay/ContextMenu/OContextMenuItem.types.ts b/web/src/lib/overlay/ContextMenu/OContextMenuItem.types.ts new file mode 100644 index 0000000000..e53bda7956 --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenuItem.types.ts @@ -0,0 +1,44 @@ +/** + * OContextMenuItem.types.ts — public types for OContextMenuItem. + */ + +import type { IconName } from "../../core/Icon/OIcon.icons"; + +/** Visual intent of the item */ +export type ContextMenuItemVariant = "default" | "destructive"; + +export interface ContextMenuItemProps { + /** Visual intent — use destructive for Delete/Remove actions */ + variant?: ContextMenuItemVariant; + /** Prevents the user from interacting with this item */ + disabled?: boolean; + /** Text used for typeahead matching (overrides text content) */ + textValue?: string; + /** Icon placed before the label — renders an OIcon internally */ + iconLeft?: IconName; + /** + * Renders a keyboard-shortcut hint (OShortcut keycaps) at the trailing edge + * of the item. Accepts a pre-symbolised string (`"⌘I"`) or an array of + * keycaps (`["⌘", "I"]`). Omit to show no hint. + */ + shortcut?: string | string[]; + /** + * Registry shortcut id — resolves the hint keys from `shortcutRegistry.ts` + * (the single source of truth) so a key change there updates this item too. + * Prefer this over `shortcut`; `shortcut` wins if both are set. + */ + shortcutId?: string; +} + +export interface ContextMenuItemEmits { + (e: "select", event: Event): void; +} + +export interface ContextMenuItemSlots { + /** Icon placed before the label */ + "icon-left"?: () => unknown; + /** Label text / content */ + default?: () => unknown; + /** Icon placed after the label */ + "icon-right"?: () => unknown; +} diff --git a/web/src/lib/overlay/ContextMenu/OContextMenuItem.vue b/web/src/lib/overlay/ContextMenu/OContextMenuItem.vue new file mode 100644 index 0000000000..644957ee72 --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenuItem.vue @@ -0,0 +1,57 @@ + + + diff --git a/web/src/lib/overlay/ContextMenu/OContextMenuLabel.spec.ts b/web/src/lib/overlay/ContextMenu/OContextMenuLabel.spec.ts new file mode 100644 index 0000000000..14c6f0d4cd --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenuLabel.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { mount } from "@vue/test-utils"; +import OContextMenuLabel from "./OContextMenuLabel.vue"; + +describe("OContextMenuLabel", () => { + it("renders slot content", () => { + const wrapper = mount(OContextMenuLabel, { + slots: { default: "kubernetes_namespace_name" }, + }); + expect(wrapper.text()).toContain("kubernetes_namespace_name"); + }); + + it("applies label token class", () => { + const wrapper = mount(OContextMenuLabel); + expect(wrapper.classes().join(" ")).toContain("text-dropdown-label"); + }); + + it("truncates long labels rather than widening the menu", () => { + const wrapper = mount(OContextMenuLabel); + expect(wrapper.classes().join(" ")).toContain("truncate"); + }); +}); diff --git a/web/src/lib/overlay/ContextMenu/OContextMenuLabel.types.ts b/web/src/lib/overlay/ContextMenu/OContextMenuLabel.types.ts new file mode 100644 index 0000000000..ba869720da --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenuLabel.types.ts @@ -0,0 +1,8 @@ +/** + * OContextMenuLabel.types.ts — public types for OContextMenuLabel. + */ + +export interface ContextMenuLabelSlots { + /** Label content — a non-interactive heading for the group beneath it */ + default?: () => unknown; +} diff --git a/web/src/lib/overlay/ContextMenu/OContextMenuLabel.vue b/web/src/lib/overlay/ContextMenu/OContextMenuLabel.vue new file mode 100644 index 0000000000..254902c987 --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenuLabel.vue @@ -0,0 +1,14 @@ + + + diff --git a/web/src/lib/overlay/ContextMenu/OContextMenuSeparator.spec.ts b/web/src/lib/overlay/ContextMenu/OContextMenuSeparator.spec.ts new file mode 100644 index 0000000000..507fcbb4d1 --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenuSeparator.spec.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { mount } from "@vue/test-utils"; +import OContextMenuSeparator from "./OContextMenuSeparator.vue"; + +describe("OContextMenuSeparator", () => { + it("renders a separator element", () => { + const wrapper = mount(OContextMenuSeparator); + expect(wrapper.element).toBeTruthy(); + }); + + it("applies separator bg class", () => { + const wrapper = mount(OContextMenuSeparator); + expect(wrapper.classes().join(" ")).toContain("bg-dropdown-separator"); + }); +}); diff --git a/web/src/lib/overlay/ContextMenu/OContextMenuSeparator.types.ts b/web/src/lib/overlay/ContextMenu/OContextMenuSeparator.types.ts new file mode 100644 index 0000000000..eca26dd156 --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenuSeparator.types.ts @@ -0,0 +1,8 @@ +/** + * OContextMenuSeparator.types.ts — public types for OContextMenuSeparator. + */ + +export interface ContextMenuSeparatorSlots { + /** Separators render no content; the slot exists for API symmetry only. */ + default?: () => unknown; +} diff --git a/web/src/lib/overlay/ContextMenu/OContextMenuSeparator.vue b/web/src/lib/overlay/ContextMenu/OContextMenuSeparator.vue new file mode 100644 index 0000000000..1f95bf5ab8 --- /dev/null +++ b/web/src/lib/overlay/ContextMenu/OContextMenuSeparator.vue @@ -0,0 +1,7 @@ + + + diff --git a/web/src/lib/overlay/Dropdown/ODropdown.vue b/web/src/lib/overlay/Dropdown/ODropdown.vue index b027e12b4e..115ab1e3ef 100644 --- a/web/src/lib/overlay/Dropdown/ODropdown.vue +++ b/web/src/lib/overlay/Dropdown/ODropdown.vue @@ -282,8 +282,8 @@ onBeforeUnmount(() => { @pointer-down-outside="handlePointerDownOutside" @focus-outside="handleFocusOutside" :class="[ - // Layout + stacking (must be above the app header/drawer: 2000/3000) - 'min-w-40 p-1 z-[6000]', + // Layout + stacking — z-menu is the shared overlay layer (tokens/base.css) + 'min-w-40 p-1 z-menu', // Surface 'bg-dropdown-bg border border-dropdown-border rounded-default shadow-md', // Typography diff --git a/web/src/lib/styles/tokens/base.css b/web/src/lib/styles/tokens/base.css index 27bd7c79c5..969f8caf08 100644 --- a/web/src/lib/styles/tokens/base.css +++ b/web/src/lib/styles/tokens/base.css @@ -296,6 +296,18 @@ own knob so it holds independent of --radius-default. */ --radius-checkbox: 0.25rem; /* 4px — checkbox tick box only */ + /* --- Stacking scale --- + Portaled floating surfaces need a shared knob, not a number typed at each + call site. The app stack, for context: header 2000, drawer/dialog scrim + 5999, drawer/dialog content 6000 (+1000 per nesting level, computed in + ODialog/ODrawer). + --z-menu sits at the base overlay layer: a menu opened from the page must + clear the header, and one opened from a dialog is portaled after that + dialog's content, so equal z + later DOM order puts it on top. + Consumed as the `z-menu` utility (registered in styles/tailwind.css) — + never as an arbitrary z-index at the call site. */ + --z-menu: 6000; /* dropdown / context menu surfaces */ + /* --- Shadow scale — hairline-border philosophy --- Only md/lg render real elevation (overlays: dialogs, menus). Cards use a 1px border, not a drop shadow — a flat surface needs NO shadow class. */ diff --git a/web/src/locales/languages/en-US.json b/web/src/locales/languages/en-US.json index ac7368d856..abfda3e2e2 100644 --- a/web/src/locales/languages/en-US.json +++ b/web/src/locales/languages/en-US.json @@ -8981,7 +8981,9 @@ "cellActions": { "copy": "Copy", "includeTerm": "Include Term", - "excludeTerm": "Exclude Term" + "excludeTerm": "Exclude Term", + "sendValueToAi": "Send Value to AI", + "sendRowToAi": "Send Row to AI" }, "indexList": { "noMatchingFields": "No matching fields found", diff --git a/web/src/plugins/logs/DetailTable.spec.ts b/web/src/plugins/logs/DetailTable.spec.ts index bd54deb92e..98e72cf8e1 100644 --- a/web/src/plugins/logs/DetailTable.spec.ts +++ b/web/src/plugins/logs/DetailTable.spec.ts @@ -237,7 +237,7 @@ describe("DetailTable Component", () => { }); it("should initialize reactive data correctly", () => { - expect(wrapper.vm.tab).toBe("json"); + expect(wrapper.vm.tab).toBe("table"); expect(wrapper.vm.selectedRelativeValue).toBe(10); expect(wrapper.vm.shouldWrapValues).toBe(true); expect(wrapper.vm.recordSizeOptions).toEqual([ @@ -307,10 +307,10 @@ describe("DetailTable Component", () => { }); // Test 11-15: Tab Functionality - it("should start with JSON tab active", () => { - expect(wrapper.vm.tab).toBe("json"); - const jsonTab = wrapper.find('[data-test="log-detail-json-tab"]'); - expect(jsonTab.exists()).toBe(true); + it("should start with Table tab active", () => { + expect(wrapper.vm.tab).toBe("table"); + const tableTab = wrapper.find('[data-test="log-detail-table-tab"]'); + expect(tableTab.exists()).toBe(true); // OTab uses Tailwind classes for active state }); @@ -442,15 +442,22 @@ describe("DetailTable Component", () => { expect(wrapper.vm.shouldWrapValues).toBe(!initialValue); }); + // Asserts the handler's observable effect rather than spying on the instance + // method: the template captures the handler into the vnode at render time, so + // a spy installed on wrapper.vm afterwards is only picked up if something + // forces a re-render — which made the old spy-based version silently depend + // on `tab` actually changing here. it("should call toggleWrapLogDetails when wrap toggle changed", async () => { - const spy = vi.spyOn(wrapper.vm, "toggleWrapLogDetails"); - wrapper.vm.tab = "table"; - await nextTick(); - + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + const initialValue = wrapper.vm.shouldWrapValues; + const wrapToggle = wrapper.find('[data-test="log-detail-wrap-values-toggle-btn"]'); await wrapToggle.trigger("click"); - - expect(spy).toHaveBeenCalled(); + + expect(setItemSpy).toHaveBeenCalledWith( + "wrap-log-details", + String(!initialValue), + ); }); it("should update localStorage when toggleWrapLogDetails called", () => { diff --git a/web/src/plugins/logs/DetailTable.vue b/web/src/plugins/logs/DetailTable.vue index 1790b478e8..359139d617 100644 --- a/web/src/plugins/logs/DetailTable.vue +++ b/web/src/plugins/logs/DetailTable.vue @@ -22,35 +22,20 @@ along with this program. If not, see .
- + + - - - - -
@@ -318,7 +303,7 @@ along with this program. If not, see . :fts-fields="correlationProps.ftsFields" :time-range="correlationProps.timeRange" :hide-dimension-filters="true" - @close="tab = 'json'" + @close="tab = 'table'" />
@@ -351,7 +336,7 @@ along with this program. If not, see . :fts-fields="correlationProps.ftsFields" :time-range="correlationProps.timeRange" :hide-dimension-filters="true" - @close="tab = 'json'" + @close="tab = 'table'" />
@@ -513,7 +498,7 @@ export default defineComponent({ }, initialTab: { type: String, - default: "json", + default: "table", }, }, methods: { @@ -546,7 +531,7 @@ export default defineComponent({ const store = useStore(); const { isDark } = useTheme(); const tableDropdownOpenMap = reactive>({}); - const tab = ref(props.initialTab || "json"); + const tab = ref(props.initialTab || "table"); const selectedRelativeValue = ref(10); const recordSizeOptions = ref>([ { label: "10", value: 10 }, @@ -659,6 +644,118 @@ export default defineComponent({ return store.state.zoConfig.service_streams_enabled !== false; }); + // Tab bar is drag-to-reorder (same pattern as the home page): the list of + // tabs that *may* be shown is derived from config, while the user's chosen + // order lives in localStorage. + const LS_TAB_ORDER_KEY = "o2_log_detail_tab_order"; + + const availableTabs = computed(() => { + const tabs = [ + { + name: "table", + label: t("common.table"), + dataTest: "log-detail-table-tab", + }, + { + name: "json", + label: t("common.json"), + dataTest: "log-detail-json-tab", + }, + ]; + if (serviceStreamsEnabled.value && config.isEnterprise === "true") { + tabs.push( + { + name: "correlated-logs", + label: t("correlation.correlatedLogs"), + dataTest: "correlated-logs-tab", + }, + { + name: "correlated-metrics", + label: t("correlation.correlatedMetrics"), + dataTest: "correlated-metrics-tab", + }, + { + name: "correlated-traces", + label: t("correlation.correlatedTraces"), + dataTest: "correlated-traces-tab", + }, + ); + } + return tabs; + }); + + type DetailTab = { name: string; label: string; dataTest: string }; + + const loadTabOrder = (): DetailTab[] => { + try { + const saved = window.localStorage.getItem(LS_TAB_ORDER_KEY); + if (saved) { + const names: string[] = JSON.parse(saved); + const ordered = names + .map((name) => availableTabs.value.find((t) => t.name === name)) + .filter(Boolean) as DetailTab[]; + // Append any tab that is available but missing from the saved order. + availableTabs.value.forEach((t) => { + if (!ordered.find((o) => o.name === t.name)) ordered.push(t); + }); + return ordered; + } + } catch { + /* ignore: fall back to the default tab order */ + } + return [...availableTabs.value]; + }; + + const tabOrder = ref(loadTabOrder()); + + // Enterprise/service-stream config arrives asynchronously, so re-sync the + // order whenever the set of available tabs changes — keeping the user's + // order and appending anything new. + watch( + () => availableTabs.value.map((t) => t.name).join(","), + () => { + const merged: DetailTab[] = []; + tabOrder.value.forEach((t) => { + const match = availableTabs.value.find((a) => a.name === t.name); + if (match) merged.push(match); + }); + availableTabs.value.forEach((a) => { + if (!merged.find((m) => m.name === a.name)) merged.push(a); + }); + tabOrder.value = merged; + }, + ); + + // OTabs reports the intended move (dragged name → target name + which side + // of the target); we apply it to our own list and persist it. + const onTabReorder = ({ + from, + to, + before = true, + }: { + from: string | number; + to: string | number; + before?: boolean; + }) => { + if (from === to) return; + const order = [...tabOrder.value]; + const fromIdx = order.findIndex((t) => t.name === from); + if (fromIdx === -1) return; + + const [moved] = order.splice(fromIdx, 1); + // Recompute the target index after removal, then insert on the chosen side. + let toIdx = order.findIndex((t) => t.name === to); + if (toIdx === -1) return; + if (!before) toIdx += 1; + order.splice(toIdx, 0, moved); + + tabOrder.value = order; + window.localStorage.setItem( + LS_TAB_ORDER_KEY, + JSON.stringify(order.map((t) => t.name)), + ); + }; + onBeforeMount(() => { if (window.localStorage.getItem("wrap-log-details") === null) { window.localStorage.setItem("wrap-log-details", "true"); @@ -897,6 +994,8 @@ export default defineComponent({ tableColumns, tableRows, serviceStreamsEnabled, + tabOrder, + onTabReorder, config, getContentSize, getDisplayValue, diff --git a/web/src/plugins/logs/SearchResult.vue b/web/src/plugins/logs/SearchResult.vue index 2707ee6332..3fba05cbdb 100644 --- a/web/src/plugins/logs/SearchResult.vue +++ b/web/src/plugins/logs/SearchResult.vue @@ -1145,7 +1145,7 @@ export default defineComponent({ const correlationDashboardProps = ref(null); const correlationLoading = ref(false); const correlationError = ref(null); - const detailTableInitialTab = ref("json"); + const detailTableInitialTab = ref("table"); const { findRelatedTelemetry, semanticGroups } = useServiceCorrelation(); // Flag to prevent duplicate correlation API calls @@ -1441,7 +1441,7 @@ export default defineComponent({ const openLogDetails = (props: any, index: number) => { searchObj.meta.showDetailTab = true; searchObj.meta.resultGrid.navigation.currentRowIndex = index; - detailTableInitialTab.value = "json"; // Reset to default tab + detailTableInitialTab.value = "table"; // Reset to default tab // Prepare correlation context (but don't open panel automatically) const logData = searchObj.data.queryResults?.hits?.[index]; diff --git a/web/src/plugins/logs/TenstackTable.spec.ts b/web/src/plugins/logs/TenstackTable.spec.ts index e41458a798..c3719467dd 100644 --- a/web/src/plugins/logs/TenstackTable.spec.ts +++ b/web/src/plugins/logs/TenstackTable.spec.ts @@ -99,8 +99,6 @@ vi.mock("@/utils/logs/statusParser", () => ({ // ── Stubs ────────────────────────────────────────────────────────────────────── const globalStubs = { JsonPreview: { template: "
" }, - CellActions: { template: "
" }, - O2AIContextAddBtn: { template: "
" }, FlexRender: { template: "" }, }; @@ -147,9 +145,9 @@ describe("TenstackTable", () => { expect(wrapper.vm.isFunctionErrorOpen).toBe(false); }); - it("initializes activeCellActionId as empty string", () => { + it("initializes contextCell as null", () => { const wrapper = mountComponent(); - expect(wrapper.vm.activeCellActionId).toBe(""); + expect(wrapper.vm.contextCell).toBeNull(); }); it("initializes expandedRowIndices as empty Set", () => { diff --git a/web/src/plugins/logs/TenstackTable.vue b/web/src/plugins/logs/TenstackTable.vue index aa480a9178..85544f3587 100644 --- a/web/src/plugins/logs/TenstackTable.vue +++ b/web/src/plugins/logs/TenstackTable.vue @@ -32,456 +32,554 @@ along with this program. If not, see . :loading="loading" :loadingProgressPercentage="loadingProgressPercentage" /> - - - + +