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 @@
+
+
+
+ emit('select', e)"
+ >
+
+
+
+
+
+
+
+
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"
/>
-
-
-
+
+
+ handleDragStart(event)"
- @end="() => handleDragEnd()"
- class="flex items-center h-8"
>
- |
-
-
+
-
+
-
-
-
- |
-
+
+
+
+
+
-
-
- |
-
-
- {{ errMsg }}
-
- |
-
-
-
-
+
+
+
+ {{ errMsg }}
+
+ |
+ |
+
-
-
- {{ t("search.functionErrorLabel") }}
+
+
+
+ {{ t("search.functionErrorLabel") }}
+
+
+
+
+ |
-
- |
-
-
-
- {{ functionErrorMsg }}
- |
-
-
+ {{ functionErrorMsg }}
+
+
+
-
-
-
-
-
- |
-
- |
-
-
-
+
+ |
-
-
-
-
-
+
+
+
+ |
+
+
+
+
+ |
+
+
+
-
-
-
-
-
-
- |
-
- |
-
- node && rowVirtualizer.measureElement(node)
"
- class="py-none px-2 flex items-center justify-start relative table-cell"
- :class="[...tableCellClass, { 'pl-4': cellIndex === 0 }]"
- :style="
- !defaultColumns && cellIndex === formattedRows[virtualRow.index].getVisibleCells().length - 1
- ? {
- flex: '1 1 auto',
- minWidth: `calc(var(--col-${cell.column.columnDef.id}-size) * 1px)`,
- width: 'auto',
- overflow: 'hidden',
- height: wrap ? '100%' : '20px',
- }
- : {
- width:
- cell.column.columnDef.id !== 'source' ||
- cell.column.columnDef.enableResizing
- ? `calc(var(--col-${cell.column.columnDef.id}-size) * 1px)`
- : wrap
- ? width - 260 - 12 + 'px'
- : 'auto',
- minWidth:
- cell.column.columnDef.id !== 'source' ||
- cell.column.columnDef.enableResizing
- ? `calc(var(--col-${cell.column.columnDef.id}-size) * 1px)`
- : undefined,
- flexShrink: '0',
- height: wrap ? '100%' : '20px',
- }
+ :class="[
+ 'absolute flex w-max items-center justify-start border-b',
+ !(formattedRows[virtualRow.index]?.original as any)
+ ?.isExpandedRow
+ ? 'cursor-pointer'
+ : 'cursor-default',
+ defaultColumns &&
+ !wrap &&
+ !(formattedRows[virtualRow.index]?.original as any)
+ ?.isExpandedRow
+ ? 'table-row'
+ : 'flex break-all',
+ (tableRows[virtualRow.index] as any)[
+ store.state.zoConfig.timestamp_column
+ ] === highlightTimestamp &&
+ !(formattedRows[virtualRow.index]?.original as any)
+ ?.isExpandedRow
+ ? 'bg-table-row-selected-bg'
+ : !(formattedRows[virtualRow.index]?.original as any)
+ ?.isExpandedRow
+ ? 'log-row-base bg-log-table-row-bg'
+ : '',
+ !(formattedRows[virtualRow.index]?.original as any)
+ ?.isExpandedRow
+ ? 'table-row-hover table-row-focus focus-visible:outline-none transition-[background-color,box-shadow] duration-120 [transition-timing-function:ease-in-out] border-b-log-table-row-border!'
+ : '',
+ ]"
+ @click="
+ !(formattedRows[virtualRow.index]?.original as any)
+ ?.isExpandedRow &&
+ handleDataRowClick(
+ tableRows[virtualRow.index],
+ virtualRow.index,
+ )
+ "
+ @keydown="
+ !(formattedRows[virtualRow.index]?.original as any)
+ ?.isExpandedRow &&
+ handleRowKeydown(
+ $event,
+ tableRows[virtualRow.index],
+ virtualRow.index,
+ )
"
- @mouseover="handleCellMouseOver(cell)"
- @mouseleave="handleCellMouseLeave()"
>
-
-
-
+
+ |
-
+ |
+
+
+
+
+
+
+ {{ cell.renderValue() }}
+
+ |
-
-
- {{ cell.renderValue() }}
-
-
-
-
- |
+
-
+
+
+
+
+
+
+
+ {{ contextCell.columnId }}
+
+
+
+
+ {{ t("logs.cellActions.copy") }}
+
+
+
+
+
+
+
+
+
+
+ {{ t("logs.cellActions.includeTerm") }}
+
+
+
+
+
+
+
+
+ {{ t("logs.cellActions.excludeTerm") }}
+
-
-
+
+
+
+
+ {{ t("logs.cellActions.sendValueToAi") }}
+
+
+ {{ t("logs.cellActions.sendRowToAi") }}
+
+
+
+
@@ -510,14 +608,19 @@ import JsonPreview from "./JsonPreview.vue";
import { useStore } from "vuex";
import { useI18n } from "vue-i18n";
import { VueDraggableNext as VueDraggable } from "vue-draggable-next";
-import CellActions from "@/plugins/logs/data-table/CellActions.vue";
import { debounce } from "lodash-es";
-import O2AIContextAddBtn from "@/components/common/O2AIContextAddBtn.vue";
+import config from "@/aws-exports";
import { extractStatusFromLog } from "@/utils/logs/statusParser";
import { useTextHighlighter } from "@/composables/useTextHighlighter";
import { useLogsHighlighter } from "@/composables/useLogsHighlighter";
+import EqualIcon from "@/components/icons/EqualIcon.vue";
+import NotEqualIcon from "@/components/icons/NotEqualIcon.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
+import OContextMenu from "@/lib/overlay/ContextMenu/OContextMenu.vue";
+import OContextMenuItem from "@/lib/overlay/ContextMenu/OContextMenuItem.vue";
+import OContextMenuLabel from "@/lib/overlay/ContextMenu/OContextMenuLabel.vue";
+import OContextMenuSeparator from "@/lib/overlay/ContextMenu/OContextMenuSeparator.vue";
import LoadingProgress from "@/components/common/LoadingProgress.vue";
interface StreamField {
@@ -661,7 +764,31 @@ const selectedStreamFtsKeys: ComputedRef
= computed(() => {
const isFunctionErrorOpen = ref(false);
-const activeCellActionId = ref("");
+// The cell the user last right-clicked — drives the context menu's contents.
+// Held as plain values (not the TanStack cell) so the menu keeps rendering
+// correctly even if the virtualizer recycles the row underneath it.
+interface ContextCell {
+ columnId: string;
+ value: unknown;
+ row: Record;
+}
+
+const contextCell = ref(null);
+
+const contextCellIsStreamField = computed(() => {
+ const columnId = contextCell.value?.columnId;
+ if (!columnId) return false;
+ return (
+ props.selectedStreamFields?.find((field) => field.name === columnId)
+ ?.isSchemaField ?? false
+ );
+});
+
+// Mirrors O2AIContextAddBtn's own gate — the AI actions only exist on
+// enterprise builds with AI turned on in the backend config.
+const aiEnabled = computed(
+ () => config.isEnterprise === "true" && !!store.state.zoConfig.ai_enabled,
+);
const highlightQuery = computed(() => {
return props.highlightQuery;
@@ -833,12 +960,8 @@ watch(
() => [hasDefaultSourceColumn.value, props.wrap],
() => {
tableCellClass.value = [
- hasDefaultSourceColumn.value && !props.wrap
- ? "table-cell"
- : "block",
- !props.wrap
- ? "overflow-hidden text-ellipsis whitespace-nowrap"
- : "",
+ hasDefaultSourceColumn.value && !props.wrap ? "table-cell" : "block",
+ !props.wrap ? "overflow-hidden text-ellipsis whitespace-nowrap" : "",
props.wrap ? "break-all" : "",
];
},
@@ -879,12 +1002,12 @@ const headers = computed(() => headerGroups.value.headers);
// Skeleton loading helpers — mirrors OTable shimmer pattern
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];
+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)
const SKEL_TIMESTAMP_PX = Math.round("2026-06-02 12:09:00.349".length * 7.2);
const skelCellWidth = (r: number, c: number): number => {
const base = SKEL_BASE_WIDTHS[c % SKEL_BASE_WIDTHS.length] ?? 60;
- const jit = SKEL_JITTER[(r + c) % SKEL_JITTER.length] ?? 0;
+ const jit = SKEL_JITTER[(r + c) % SKEL_JITTER.length] ?? 0;
return Math.max(25, Math.min(85, base + jit));
};
// Mirror the exact width/flex logic from real cells so the skeleton columns align perfectly.
@@ -892,13 +1015,13 @@ const skelCellWidth = (r: number, c: number): number => {
// All other columns: fixed width from the CSS variable (same as real rows).
const skelTdStyle = (header: any, c: number): Record => {
const colId = header.column.id;
- const isStretchSource = colId === 'source' && !header.column.getCanResize();
- if (isStretchSource) return { flex: '1 1 0', minWidth: '0' };
+ const isStretchSource = colId === "source" && !header.column.getCanResize();
+ if (isStretchSource) return { flex: "1 1 0", minWidth: "0" };
const w = `calc(var(--col-${colId}-size) * 1px)`;
if (!props.defaultColumns && c === headers.value.length - 1) {
- return { flex: '1 1 auto', minWidth: w, width: 'auto', overflow: 'hidden' };
+ return { flex: "1 1 auto", minWidth: w, width: "auto", overflow: "hidden" };
}
- return { width: w, minWidth: w, flexShrink: '0' };
+ return { width: w, minWidth: w, flexShrink: "0" };
};
watch(
@@ -986,6 +1109,29 @@ const addSearchTerm = (
) => {
emits("addSearchTerm", field, field_value, action);
};
+
+/**
+ * A log row is arbitrary JSON, so a right-clicked cell holds an unknown value,
+ * while the filter builder downstream (getFilterExpressionByFieldType) takes a
+ * primitive and interpolates it into the query expression.
+ *
+ * Primitives pass through untouched — the builder needs the real type to emit
+ * the unquoted form for int64/float64/boolean schema fields. null/undefined
+ * collapse to the "null" sentinel the builder already special-cases into
+ * `is null` / `is not null`. Objects and arrays are serialised, so a nested
+ * value reaches the query as its JSON rather than "[object Object]".
+ */
+const toSearchTermValue = (value: unknown): string | number | boolean => {
+ if (
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ ) {
+ return value;
+ }
+ if (value === null || value === undefined) return "null";
+ return JSON.stringify(value);
+};
const addFieldToTable = (value: string) => {
emits("addFieldToTable", value);
};
@@ -1177,25 +1323,39 @@ const handleRowKeydown = (event: KeyboardEvent, row: any, index: number) => {
const expandFunctionError = () => {
isFunctionErrorOpen.value = !isFunctionErrorOpen.value;
};
-// Specific function that updates the active cell action ID
-const updateActiveCell = (cell?: { id: string; column: { id: string } }) => {
- if (!cell) {
- activeCellActionId.value = "";
- } else {
- activeCellActionId.value = `${cell.id}_${cell.column.id}`;
+// Capture-phase gate on the whole table, so it runs before the per-cell handler
+// below. Only data cells offer actions — right-clicking a header or the
+// expanded JSON row would otherwise open an empty menu. reka-ui checks
+// `defaultPrevented` before opening, so preventing here suppresses it.
+const DATA_CELL_SELECTOR = "td[data-test^='log-table-column-']";
+
+const handleTableContextMenu = (event: MouseEvent) => {
+ contextCell.value = null;
+ const target = event.target;
+ if (!(target instanceof Element) || !target.closest?.(DATA_CELL_SELECTOR)) {
+ event.preventDefault();
}
};
-// Debounced version of the function
-const debounceCellAction = debounce(updateActiveCell, 250);
-
-// Event handlers for mouse over and mouse leave
-const handleCellMouseOver = (cell: { id: string; column: { id: string } }) => {
- debounceCellAction(cell);
+// Records which cell the right-click landed on. Runs on the td, so it fires
+// before OContextMenu's own handler on the table (which defers a tick) — by the
+// time the menu renders, `contextCell` already describes the clicked cell.
+const handleCellContextMenu = (cell: {
+ column: { id: string };
+ row: { original: unknown };
+}) => {
+ const row = (cell.row.original ?? {}) as Record;
+ contextCell.value = {
+ columnId: cell.column.id,
+ value: row[cell.column.id],
+ row,
+ };
};
-const handleCellMouseLeave = () => {
- activeCellActionId.value = "";
+// Drop the reference once the menu closes so a recycled virtual row can't be
+// held alive by a stale row object.
+const onContextMenuOpenChange = (open: boolean) => {
+ if (!open) contextCell.value = null;
};
const viewTrace = (row: any) => {
@@ -1291,11 +1451,6 @@ defineExpose({
box-shadow: inset 0.1875rem 0 0 var(--color-accent) !important;
}
-.table-row-hover:hover .ai-btn {
- visibility: visible !important;
- z-index: 2;
-}
-
/* This "table" lays out entirely with flexbox + explicit column widths — it does
not use native table column layout. Rendering table/thead/tbody as block boxes
makes position:relative (the containing block for the position:absolute
@@ -1342,26 +1497,41 @@ defineExpose({
.logs-skel-pill {
background: linear-gradient(
90deg,
- var(--color-skeleton-base) 0%,
+ var(--color-skeleton-base) 0%,
var(--color-skeleton-highlight) 50%,
- var(--color-skeleton-base) 100%
+ var(--color-skeleton-base) 100%
);
background-size: 200% 100%;
animation: logs-skel-shimmer 1.5s ease-in-out infinite;
}
@keyframes logs-skel-shimmer {
- 0% { background-position: 200% 0; }
- 100% { background-position: -200% 0; }
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
}
@keyframes logs-skel-row-in {
- from { opacity: 0; transform: translateY(0.125rem); }
- to { opacity: 1; transform: translateY(0); }
+ from {
+ opacity: 0;
+ transform: translateY(0.125rem);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
@media (prefers-reduced-motion: reduce) {
- .logs-skel-row { opacity: 1; animation: none; }
- .logs-skel-pill { animation: none; }
+ .logs-skel-row {
+ opacity: 1;
+ animation: none;
+ }
+ .logs-skel-pill {
+ animation: none;
+ }
}
diff --git a/web/src/styles/tailwind.css b/web/src/styles/tailwind.css
index b89371a9dd..90186f5837 100644
--- a/web/src/styles/tailwind.css
+++ b/web/src/styles/tailwind.css
@@ -22,6 +22,14 @@
@source "../**/*.{vue,js,ts}";
+/* Stacking utility for portaled floating surfaces. Tailwind v4 has no z-index
+ theme namespace (`z-*` only takes bare numbers or arbitrary values), so the
+ token is exposed as a real utility here rather than left as an arbitrary
+ z-index at each call site. Value lives in tokens/base.css — change it there. */
+@utility z-menu {
+ z-index: var(--z-menu);
+}
+
/*
* Light↔dark mode switch — soft curtain sweep (View Transitions API, started
* by switchThemeMode in utils/theme.ts). The outgoing theme holds fully opaque