diff --git a/tests/ui-testing/package-lock.json b/tests/ui-testing/package-lock.json
index 9609f8098f..89201ab0b6 100644
--- a/tests/ui-testing/package-lock.json
+++ b/tests/ui-testing/package-lock.json
@@ -15,6 +15,7 @@
"googleapis": "^171.4.0",
"pixelmatch": "^5.3.0",
"pngjs": "^7.0.0",
+ "uuid": "^14.0.0",
"winston": "^3.11.0"
},
"devDependencies": {
@@ -251,6 +252,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
@@ -1276,6 +1278,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
+ "node_modules/uuid": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
+ "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
diff --git a/tests/ui-testing/package.json b/tests/ui-testing/package.json
index 9476831e6f..a0cede352a 100644
--- a/tests/ui-testing/package.json
+++ b/tests/ui-testing/package.json
@@ -24,6 +24,7 @@
"googleapis": "^171.4.0",
"pixelmatch": "^5.3.0",
"pngjs": "^7.0.0",
+ "uuid": "^14.0.0",
"winston": "^3.11.0"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/lib/core/Button/Button.spec.ts b/web/src/lib/core/Button/Button.spec.ts
new file mode 100644
index 0000000000..4e970ec19f
--- /dev/null
+++ b/web/src/lib/core/Button/Button.spec.ts
@@ -0,0 +1,167 @@
+import { describe, it, expect, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import OButton from './Button.vue'
+
+describe('OButton', () => {
+ // --- Slots ---
+
+ it('renders default slot content', () => {
+ const wrapper = mount(OButton, { slots: { default: 'Save' } })
+ expect(wrapper.text()).toBe('Save')
+ })
+
+ it('renders icon-left slot', () => {
+ const wrapper = mount(OButton, {
+ slots: { 'icon-left': '← ' },
+ })
+ expect(wrapper.find('[data-testid="icon-left"]').exists()).toBe(true)
+ })
+
+ it('renders icon-right slot', () => {
+ const wrapper = mount(OButton, {
+ slots: { 'icon-right': '→ ' },
+ })
+ expect(wrapper.find('[data-testid="icon-right"]').exists()).toBe(true)
+ })
+
+ // --- Props ---
+
+ it('defaults to type="button"', () => {
+ const wrapper = mount(OButton)
+ expect(wrapper.attributes('type')).toBe('button')
+ })
+
+ it('forwards the type prop to the native button', () => {
+ const wrapper = mount(OButton, { props: { type: 'submit' } })
+ expect(wrapper.attributes('type')).toBe('submit')
+ })
+
+ it('sets the disabled attribute when disabled prop is true', () => {
+ const wrapper = mount(OButton, { props: { disabled: true } })
+ expect(wrapper.attributes('disabled')).toBeDefined()
+ })
+
+ it('sets the disabled attribute when loading prop is true', () => {
+ const wrapper = mount(OButton, { props: { loading: true } })
+ expect(wrapper.attributes('disabled')).toBeDefined()
+ })
+
+ // --- Emits ---
+
+ it('emits click with the MouseEvent when clicked', async () => {
+ const wrapper = mount(OButton)
+ await wrapper.trigger('click')
+ const emitted = wrapper.emitted('click')
+ expect(emitted).toHaveLength(1)
+ expect(emitted![0][0]).toBeInstanceOf(MouseEvent)
+ })
+
+ it('does not emit click when disabled', async () => {
+ const wrapper = mount(OButton, { props: { disabled: true } })
+ await wrapper.trigger('click')
+ expect(wrapper.emitted('click')).toBeUndefined()
+ })
+
+ it('does not emit click when loading', async () => {
+ const wrapper = mount(OButton, { props: { loading: true } })
+ await wrapper.trigger('click')
+ expect(wrapper.emitted('click')).toBeUndefined()
+ })
+
+ // --- ARIA ---
+
+ it('sets aria-disabled="true" when disabled', () => {
+ const wrapper = mount(OButton, { props: { disabled: true } })
+ expect(wrapper.attributes('aria-disabled')).toBe('true')
+ })
+
+ it('sets aria-disabled="true" when loading', () => {
+ const wrapper = mount(OButton, { props: { loading: true } })
+ expect(wrapper.attributes('aria-disabled')).toBe('true')
+ })
+
+ it('sets aria-busy="true" when loading', () => {
+ const wrapper = mount(OButton, { props: { loading: true } })
+ expect(wrapper.attributes('aria-busy')).toBe('true')
+ })
+
+ it('does not set aria-busy when not loading', () => {
+ const wrapper = mount(OButton)
+ expect(wrapper.attributes('aria-busy')).toBeUndefined()
+ })
+
+ it('does not set aria-disabled when not disabled or loading', () => {
+ const wrapper = mount(OButton)
+ expect(wrapper.attributes('aria-disabled')).toBeUndefined()
+ })
+
+ // --- Variant classes ---
+
+ it('applies primary variant classes by default', () => {
+ const wrapper = mount(OButton)
+ expect(wrapper.classes().join(' ')).toContain('tw:bg-button-primary')
+ })
+
+ it('applies secondary variant classes', () => {
+ const wrapper = mount(OButton, { props: { variant: 'secondary' } })
+ expect(wrapper.classes().join(' ')).toContain('tw:bg-button-secondary')
+ })
+
+ it('applies outline variant classes', () => {
+ const wrapper = mount(OButton, { props: { variant: 'outline' } })
+ expect(wrapper.classes().join(' ')).toContain('tw:text-button-outline-text')
+ })
+
+ it('applies ghost variant classes', () => {
+ const wrapper = mount(OButton, { props: { variant: 'ghost' } })
+ expect(wrapper.classes().join(' ')).toContain('tw:text-button-ghost-text')
+ })
+
+ it('applies destructive variant classes', () => {
+ const wrapper = mount(OButton, { props: { variant: 'destructive' } })
+ expect(wrapper.classes().join(' ')).toContain('tw:bg-button-destructive')
+ })
+
+ // --- Size classes ---
+
+ it('applies md size classes by default', () => {
+ const wrapper = mount(OButton)
+ expect(wrapper.classes().join(' ')).toContain('tw:h-10')
+ })
+
+ it('applies sm size classes', () => {
+ const wrapper = mount(OButton, { props: { size: 'sm' } })
+ expect(wrapper.classes().join(' ')).toContain('tw:h-8')
+ })
+
+ it('applies lg size classes', () => {
+ const wrapper = mount(OButton, { props: { size: 'lg' } })
+ expect(wrapper.classes().join(' ')).toContain('tw:h-12')
+ })
+
+ it('applies icon size classes', () => {
+ const wrapper = mount(OButton, { props: { size: 'icon' } })
+ expect(wrapper.classes().join(' ')).toContain('tw:size-9')
+ })
+
+ // --- Keyboard ---
+
+ it('emits click on Enter key (native button behaviour)', async () => {
+ const wrapper = mount(OButton)
+ await wrapper.trigger('keydown', { key: 'Enter' })
+ // Native button fires click on Enter — we trust browser behaviour;
+ // ensure the component does not suppress it when enabled.
+ // Trigger click directly to confirm handler works.
+ await wrapper.trigger('click')
+ expect(wrapper.emitted('click')).toHaveLength(1)
+ })
+
+ // --- Attrs passthrough ---
+
+ it('passes extra attributes to the native button', () => {
+ const wrapper = mount(OButton, {
+ attrs: { 'data-testid': 'my-btn' },
+ })
+ expect(wrapper.attributes('data-testid')).toBe('my-btn')
+ })
+})
diff --git a/web/src/lib/core/Button/Button.types.ts b/web/src/lib/core/Button/Button.types.ts
new file mode 100644
index 0000000000..ad9a9bfd27
--- /dev/null
+++ b/web/src/lib/core/Button/Button.types.ts
@@ -0,0 +1,36 @@
+/**
+ * Button.types.ts — single source of truth for all OButton public types.
+ * No types should be defined inline in Button.vue.
+ */
+
+/** Visual style variant — design is baked in, no style override props */
+export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive'
+
+/** Size controls height, padding, and font-size only */
+export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon'
+
+export interface ButtonProps {
+ /** Visual style variant */
+ variant?: ButtonVariant
+ /** Component size */
+ size?: ButtonSize
+ /** Disables the button and all interaction */
+ disabled?: boolean
+ /** Shows loading state and disables interaction */
+ loading?: boolean
+ /** Native button type attribute */
+ type?: 'button' | 'submit' | 'reset'
+}
+
+export interface ButtonEmits {
+ (e: 'click', event: MouseEvent): void
+}
+
+export interface ButtonSlots {
+ /** Main label / content */
+ default?: () => unknown
+ /** Icon placed before the label */
+ 'icon-left'?: () => unknown
+ /** Icon placed after the label */
+ 'icon-right'?: () => unknown
+}
diff --git a/web/src/lib/core/Button/Button.vue b/web/src/lib/core/Button/Button.vue
new file mode 100644
index 0000000000..8e4b4e8b66
--- /dev/null
+++ b/web/src/lib/core/Button/Button.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/core/RefreshButton/RefreshButton.types.ts b/web/src/lib/core/RefreshButton/RefreshButton.types.ts
new file mode 100644
index 0000000000..1cd9c45a9c
--- /dev/null
+++ b/web/src/lib/core/RefreshButton/RefreshButton.types.ts
@@ -0,0 +1,12 @@
+export interface RefreshButtonProps {
+ /** Unix millisecond timestamp of the last completed query */
+ lastRunAt?: number | null
+ /** Mirrors the page's loading state to spin the icon and disable the button */
+ loading?: boolean
+ /** Disables the button independently of loading */
+ disabled?: boolean
+}
+
+export interface RefreshButtonEmits {
+ (e: 'click', event: MouseEvent): void
+}
diff --git a/web/src/lib/core/RefreshButton/RefreshButton.vue b/web/src/lib/core/RefreshButton/RefreshButton.vue
new file mode 100644
index 0000000000..a4ff5794de
--- /dev/null
+++ b/web/src/lib/core/RefreshButton/RefreshButton.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+ {{ relativeTime || 'just now' }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/lib/styles/tailwind.css b/web/src/lib/styles/tailwind.css
new file mode 100644
index 0000000000..9e4af1ff9f
--- /dev/null
+++ b/web/src/lib/styles/tailwind.css
@@ -0,0 +1,11 @@
+/* ============================================================
+ O2 Component Library — CSS entry point
+ Import this file once in the app to activate all tokens.
+ ============================================================ */
+
+@import "tailwindcss" prefix(tw);
+
+@import "./tokens/base.css";
+@import "./tokens/semantic.css";
+@import "./tokens/component.css";
+@import "./tokens/dark.css";
diff --git a/web/src/lib/styles/tokens/base.css b/web/src/lib/styles/tokens/base.css
new file mode 100644
index 0000000000..d435057c2b
--- /dev/null
+++ b/web/src/lib/styles/tokens/base.css
@@ -0,0 +1,67 @@
+/* ============================================================
+ BASE TOKENS — raw palette, spacing, radius, shadows
+ These are NEVER referenced directly in components.
+ Components use semantic or component tokens only.
+ ============================================================ */
+
+:root {
+ /* --- Primary palette --- */
+ --color-primary-50: #eff6ff;
+ --color-primary-100: #dbeafe;
+ --color-primary-200: #bfdbfe;
+ --color-primary-300: #93c5fd;
+ --color-primary-400: #60a5fa;
+ --color-primary-500: #3b82f6;
+ --color-primary-600: #2563eb;
+ --color-primary-700: #1d4ed8;
+ --color-primary-800: #1e40af;
+ --color-primary-900: #1e3a8a;
+
+ /* --- Grey palette --- */
+ --color-grey-0: #ffffff;
+ --color-grey-50: #f9fafb;
+ --color-grey-100: #f3f4f6;
+ --color-grey-200: #e5e7eb;
+ --color-grey-300: #d1d5db;
+ --color-grey-400: #9ca3af;
+ --color-grey-500: #6b7280;
+ --color-grey-600: #4b5563;
+ --color-grey-700: #374151;
+ --color-grey-800: #1f2937;
+ --color-grey-900: #111827;
+ --color-grey-950: #030712;
+
+ /* --- Error palette --- */
+ --color-error-50: #fef2f2;
+ --color-error-100: #fee2e2;
+ --color-error-400: #f87171;
+ --color-error-500: #ef4444;
+ --color-error-600: #dc2626;
+ --color-error-700: #b91c1c;
+
+ /* --- Success palette --- */
+ --color-success-50: #f0fdf4;
+ --color-success-500: #22c55e;
+ --color-success-700: #15803d;
+
+ /* --- Warning palette --- */
+ --color-warning-50: #fffbeb;
+ --color-warning-500: #f59e0b;
+ --color-warning-700: #b45309;
+
+ /* --- White / Black --- */
+ --color-white: #ffffff;
+ --color-black: #000000;
+
+ /* --- Radius scale --- */
+ --radius-sm: 0.25rem; /* 4px */
+ --radius-md: 0.375rem; /* 6px */
+ --radius-lg: 0.5rem; /* 8px */
+ --radius-xl: 0.75rem; /* 12px */
+ --radius-full: 9999px;
+
+ /* --- Shadow scale --- */
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+}
diff --git a/web/src/lib/styles/tokens/component.css b/web/src/lib/styles/tokens/component.css
new file mode 100644
index 0000000000..29eaf82276
--- /dev/null
+++ b/web/src/lib/styles/tokens/component.css
@@ -0,0 +1,44 @@
+/* ============================================================
+ COMPONENT TOKENS — per-component design decisions
+ Each section is owned exclusively by one component.
+ ============================================================ */
+
+@theme inline {
+ /* ---- Button ------------------------------------------------ */
+
+ /* primary */
+ --color-button-primary: var(--color-primary-600);
+ --color-button-primary-hover: var(--color-primary-700);
+ --color-button-primary-active: var(--color-primary-800);
+ --color-button-primary-disabled: var(--color-primary-300);
+ --color-button-primary-foreground: var(--color-white);
+ --color-button-primary-focus-ring: var(--color-primary-400);
+
+ /* secondary */
+ --color-button-secondary: var(--color-grey-100);
+ --color-button-secondary-hover: var(--color-grey-200);
+ --color-button-secondary-active: var(--color-grey-300);
+ --color-button-secondary-disabled: var(--color-grey-100);
+ --color-button-secondary-foreground: var(--color-grey-800);
+ --color-button-secondary-focus-ring: var(--color-grey-400);
+
+ /* outline */
+ --color-button-outline-text: var(--color-grey-800);
+ --color-button-outline-border: var(--color-grey-300);
+ --color-button-outline-hover-bg: var(--color-grey-50);
+ --color-button-outline-hover-border: var(--color-grey-400);
+ --color-button-outline-active-bg: var(--color-grey-100);
+ --color-button-outline-focus-ring: var(--color-grey-400);
+
+ /* ghost */
+ --color-button-ghost-text: var(--color-grey-700);
+ --color-button-ghost-hover-bg: var(--color-grey-100);
+ --color-button-ghost-active-bg: var(--color-grey-200);
+ --color-button-ghost-focus-ring: var(--color-grey-400);
+
+ /* destructive */
+ --color-button-destructive: var(--color-error-600);
+ --color-button-destructive-hover: var(--color-error-700);
+ --color-button-destructive-disabled: var(--color-error-300);
+ --color-button-destructive-foreground: var(--color-white);
+}
diff --git a/web/src/lib/styles/tokens/dark.css b/web/src/lib/styles/tokens/dark.css
new file mode 100644
index 0000000000..7f9c3d8381
--- /dev/null
+++ b/web/src/lib/styles/tokens/dark.css
@@ -0,0 +1,46 @@
+/* ============================================================
+ DARK MODE TOKEN OVERRIDES
+ Overrides semantic (and component) tokens for dark themes.
+ ============================================================ */
+
+@custom-variant dark (&:where(.dark, .dark *));
+
+.dark {
+ :root, & {
+ /* --- Text --- */
+ --color-text-primary: var(--color-grey-50);
+ --color-text-secondary: var(--color-grey-400);
+ --color-text-disabled: var(--color-grey-600);
+ --color-text-inverse: var(--color-grey-900);
+
+ /* --- Surfaces --- */
+ --color-surface-base: var(--color-grey-950);
+ --color-surface-panel: var(--color-grey-900);
+ --color-surface-overlay: var(--color-grey-800);
+
+ /* --- Borders --- */
+ --color-border-default: var(--color-grey-700);
+ --color-border-strong: var(--color-grey-500);
+
+ /* ---- Button overrides ---- */
+
+ /* secondary (darkened surface) */
+ --color-button-secondary: var(--color-grey-800);
+ --color-button-secondary-hover: var(--color-grey-700);
+ --color-button-secondary-active: var(--color-grey-600);
+ --color-button-secondary-disabled: var(--color-grey-800);
+ --color-button-secondary-foreground: var(--color-grey-100);
+
+ /* outline */
+ --color-button-outline-text: var(--color-grey-100);
+ --color-button-outline-border: var(--color-grey-600);
+ --color-button-outline-hover-bg: var(--color-grey-800);
+ --color-button-outline-hover-border: var(--color-grey-500);
+ --color-button-outline-active-bg: var(--color-grey-700);
+
+ /* ghost */
+ --color-button-ghost-text: var(--color-grey-200);
+ --color-button-ghost-hover-bg: var(--color-grey-800);
+ --color-button-ghost-active-bg: var(--color-grey-700);
+ }
+}
diff --git a/web/src/lib/styles/tokens/semantic.css b/web/src/lib/styles/tokens/semantic.css
new file mode 100644
index 0000000000..703fb78b6b
--- /dev/null
+++ b/web/src/lib/styles/tokens/semantic.css
@@ -0,0 +1,52 @@
+/* ============================================================
+ SEMANTIC TOKENS — maps base palette to intent/meaning
+ Used directly in components for shared design decisions.
+ ============================================================ */
+
+@theme inline {
+ /* --- Text --- */
+ --color-text-primary: var(--color-grey-900);
+ --color-text-secondary: var(--color-grey-500);
+ --color-text-disabled: var(--color-grey-400);
+ --color-text-inverse: var(--color-white);
+
+ /* --- Surfaces --- */
+ --color-surface-base: var(--color-white);
+ --color-surface-panel: var(--color-grey-50);
+ --color-surface-overlay: var(--color-white);
+
+ /* --- Borders --- */
+ --color-border-default: var(--color-grey-200);
+ --color-border-strong: var(--color-grey-400);
+
+ /* --- Brand / Primary --- */
+ --color-primary-50: var(--color-primary-50);
+ --color-primary-100: var(--color-primary-100);
+ --color-primary-200: var(--color-primary-200);
+ --color-primary-300: var(--color-primary-300);
+ --color-primary-400: var(--color-primary-400);
+ --color-primary-500: var(--color-primary-500);
+ --color-primary-600: var(--color-primary-600);
+ --color-primary-700: var(--color-primary-700);
+ --color-primary-800: var(--color-primary-800);
+ --color-primary-900: var(--color-primary-900);
+
+ /* --- Error / Danger --- */
+ --color-error-50: var(--color-error-50);
+ --color-error-100: var(--color-error-100);
+ --color-error-400: var(--color-error-400);
+ --color-error-500: var(--color-error-500);
+ --color-error-600: var(--color-error-600);
+ --color-error-700: var(--color-error-700);
+
+ /* --- Radius / Shadow (semantic aliases) --- */
+ --radius-sm: var(--radius-sm);
+ --radius-md: var(--radius-md);
+ --radius-lg: var(--radius-lg);
+ --radius-xl: var(--radius-xl);
+ --radius-full: var(--radius-full);
+
+ --shadow-sm: var(--shadow-sm);
+ --shadow-md: var(--shadow-md);
+ --shadow-lg: var(--shadow-lg);
+}
diff --git a/web/src/plugins/logs/Index.vue b/web/src/plugins/logs/Index.vue
index 2458f1bf98..82c414ea7b 100644
--- a/web/src/plugins/logs/Index.vue
+++ b/web/src/plugins/logs/Index.vue
@@ -287,6 +287,7 @@ along with this program. If not, see .
@update:recordsPerPage="getMoreDataRecordsPerPage"
@expandlog="toggleExpandLog"
@send-to-ai-chat="sendToAiChat"
+ @run-query="searchData"
/>
diff --git a/web/src/plugins/logs/SearchResult.vue b/web/src/plugins/logs/SearchResult.vue
index 3e44524750..b26a80ec12 100644
--- a/web/src/plugins/logs/SearchResult.vue
+++ b/web/src/plugins/logs/SearchResult.vue
@@ -123,6 +123,13 @@ along with this program. If not, see .
{{ t("volumeInsights.analyzeTooltipLogs") }}
+
diff --git a/web/src/plugins/traces/SearchResult.spec.ts b/web/src/plugins/traces/SearchResult.spec.ts
index c55f45bc78..1139b2dfdb 100644
--- a/web/src/plugins/traces/SearchResult.spec.ts
+++ b/web/src/plugins/traces/SearchResult.spec.ts
@@ -341,6 +341,7 @@ describe("SearchResult", () => {
"search:timeboxed",
"get:traceDetails",
"metrics:filters-updated",
+ "run-query",
];
expect(wrapper.vm.$options.emits).toEqual(expectedEmits);
diff --git a/web/src/plugins/traces/SearchResult.vue b/web/src/plugins/traces/SearchResult.vue
index ec8223e98a..8112c40337 100644
--- a/web/src/plugins/traces/SearchResult.vue
+++ b/web/src/plugins/traces/SearchResult.vue
@@ -55,12 +55,19 @@ along with this program. If not, see .
color="primary"
icon="timeline"
:label="t('volumeInsights.insightsButtonLabel')"
- class="analyze-button tw:h-[2rem] tw:text-[0.75rem]! tw:tracking-[0.03rem]! tw:font-bold! tw:mr-[0.5rem]"
+ class="analyze-button tw:h-[2rem] tw:text-[0.75rem]! tw:tracking-[0.03rem]! tw:font-bold!"
@click.stop="openUnifiedAnalysisDashboard"
data-test="insights-button"
>
{{ t("volumeInsights.analyzeTooltipTraces") }}
+
@@ -159,7 +166,7 @@ along with this program. If not, see .