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 @@ + + + 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") }} +
@@ -512,10 +519,12 @@ import TelemetryCorrelationDashboard from "@/plugins/correlation/TelemetryCorrel import type { TelemetryContext } from "@/utils/telemetryCorrelation"; import { useServiceCorrelation } from "@/composables/useServiceCorrelation"; import config from "@/aws-exports"; +import ORefreshButton from "@/lib/core/RefreshButton/RefreshButton.vue"; export default defineComponent({ name: "SearchResult", components: { + ORefreshButton, DetailTable: defineAsyncComponent(() => import("./DetailTable.vue")), ChartRenderer: defineAsyncComponent( () => import("@/components/dashboards/panels/ChartRenderer.vue"), @@ -545,6 +554,7 @@ export default defineComponent({ "update:recordsPerPage", "update:columnSizes", "sendToAiChat", + "run-query", ], props: { expandedLogs: { @@ -1338,6 +1348,15 @@ export default defineComponent({ return searchObj.meta.resetPlotChart; }); + watch( + () => searchObj.loading, + (loading, wasLoading) => { + if (wasLoading && !loading && searchObj.meta.searchApplied) { + searchObj.meta.lastRunAt = Date.now(); + } + }, + ); + watch(resetPlotChart, (newVal) => { if (newVal) { plotChart.value = {}; diff --git a/web/src/plugins/traces/Index.vue b/web/src/plugins/traces/Index.vue index 04b4d9a6b9..bfd4c636c2 100644 --- a/web/src/plugins/traces/Index.vue +++ b/web/src/plugins/traces/Index.vue @@ -245,6 +245,7 @@ along with this program. If not, see . @update:sort="runQueryOnSort" @shareLink="copyTracesUrl" @metrics:filters-updated="onMetricsFiltersUpdated" + @run-query="searchData" />
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 .