diff --git a/AGENTS.md b/AGENTS.md
index 1d09a7e48..16917c5bc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -283,6 +283,54 @@ npm run cypress:open
For detailed testing instructions, see `web/cypress/CYPRESS_TESTING_GUIDE.md`
+### Cypress Component Testing
+
+#### Overview
+
+Cypress component tests mount individual React components in isolation, without requiring a running OpenShift cluster. They are useful for testing component rendering, user interactions, and visual behavior with fast feedback.
+
+- **Test location**: `web/cypress/component/`
+- **Support file**: `web/cypress/support/component.ts`
+- **Config**: `component` section in `web/cypress.config.ts`
+
+#### When to Create Component Tests
+
+- Testing a component's rendering logic (conditional display, empty states)
+- Verifying props are handled correctly
+- Validating user interactions within a single component
+- When E2E tests would be overkill for the behavior under test
+
+#### Quick Test Commands
+
+```bash
+cd web
+
+# Interactive mode
+npm run cypress:open:component
+
+# Headless mode - all component tests
+npm run cypress:run:component
+
+# Run a single component test file
+npx cypress run --component --spec cypress/component/labels.cy.tsx
+```
+
+#### Writing a Component Test
+
+Component test files use the `.cy.tsx` extension and go in `web/cypress/component/`:
+
+```typescript
+import React from 'react';
+import { MyComponent } from '../../src/components/MyComponent';
+
+describe('MyComponent', () => {
+ it('renders correctly', () => {
+ cy.mount();
+ cy.contains('expected text').should('be.visible');
+ });
+});
+```
+
### Release Pipeline:
- **Konflux**: Handles CI/CD and release automation
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2e9d26d38..f74d2cf23 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -411,6 +411,23 @@ cd web/cypress
npm run cypress:run --spec "cypress/e2e/**/regression/**"
```
+### Component Tests (Cypress)
+
+For testing individual React components in isolation (no cluster required):
+
+- Test files: `web/cypress/component/` (`.cy.tsx` extension)
+- Support file: `web/cypress/support/component.ts`
+
+```bash
+cd web
+
+# Interactive mode
+npm run cypress:open:component
+
+# Headless mode
+npm run cypress:run:component
+```
+
---
## Internationalization (i18n)
diff --git a/config/perses-dashboards.patch.json b/config/perses-dashboards.patch.json
index 2b90e7ac9..79d650595 100644
--- a/config/perses-dashboards.patch.json
+++ b/config/perses-dashboards.patch.json
@@ -119,5 +119,18 @@
"component": { "$codeRef": "DashboardPage" }
}
}
+ },
+ {
+ "op": "add",
+ "path": "/extensions/1",
+ "value": {
+ "type": "ols.tool-ui",
+ "properties": {
+ "id": "mcp-obs/show-timeseries",
+ "component": {
+ "$codeRef": "ols-tool-ui.ShowTimeseries"
+ }
+ }
+ }
}
]
diff --git a/web/cypress.config.ts b/web/cypress.config.ts
index 6c6a516b5..b2a94f02c 100644
--- a/web/cypress.config.ts
+++ b/web/cypress.config.ts
@@ -3,6 +3,7 @@ import * as fs from 'fs-extra';
import * as console from 'console';
import * as path from 'path';
import registerCypressGrep from '@cypress/grep/src/plugin';
+import { DefinePlugin, NormalModuleReplacementPlugin } from 'webpack';
export default defineConfig({
screenshotsFolder: './cypress/screenshots',
@@ -159,4 +160,61 @@ export default defineConfig({
experimentalMemoryManagement: true,
experimentalStudio: true,
},
+ component: {
+ devServer: {
+ framework: 'react',
+ bundler: 'webpack',
+ webpackConfig: {
+ resolve: {
+ extensions: ['.ts', '.tsx', '.js', '.jsx'],
+ alias: {
+ '@perses-dev/plugin-system': path.resolve(__dirname, 'cypress/component/mocks/perses-plugin-system.tsx'),
+ '@perses-dev/dashboards': path.resolve(__dirname, 'cypress/component/mocks/perses-dashboards.tsx'),
+ '@perses-dev/prometheus-plugin': path.resolve(__dirname, 'cypress/component/mocks/perses-prometheus-plugin.ts'),
+ },
+ },
+ module: {
+ rules: [
+ {
+ test: /\.(jsx?|tsx?)$/,
+ exclude: /node_modules/,
+ use: { loader: 'swc-loader' },
+ },
+ {
+ test: /\.scss$/,
+ exclude: /node_modules\/(?!(@patternfly|@openshift-console\/plugin-shared)\/).*/,
+ use: ['style-loader', 'css-loader', 'sass-loader'],
+ },
+ {
+ test: /\.css$/,
+ use: ['style-loader', 'css-loader'],
+ },
+ {
+ test: /\.(png|jpg|jpeg|gif|svg|woff2?|ttf|eot|otf)(\?.*$|$)/,
+ type: 'asset/resource',
+ },
+ {
+ test: /\.m?js/,
+ resolve: { fullySpecified: false },
+ },
+ ],
+ },
+ plugins: [
+ new DefinePlugin({
+ 'process.env.I18N_NAMESPACE': JSON.stringify('plugin__monitoring-plugin'),
+ }),
+ new NormalModuleReplacementPlugin(
+ /helpers\/OlsToolUIPersesWrapper/,
+ path.resolve(__dirname, 'cypress/component/mocks/OlsToolUIPersesWrapper.tsx'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /helpers\/AddToDashboardButton/,
+ path.resolve(__dirname, 'cypress/component/mocks/AddToDashboardButton.tsx'),
+ ),
+ ],
+ },
+ },
+ specPattern: './cypress/component/**/*.cy.{js,jsx,ts,tsx}',
+ supportFile: './cypress/support/component.ts',
+ },
});
\ No newline at end of file
diff --git a/web/cypress/CYPRESS_TESTING_GUIDE.md b/web/cypress/CYPRESS_TESTING_GUIDE.md
index 697caf6f5..071d03f80 100644
--- a/web/cypress/CYPRESS_TESTING_GUIDE.md
+++ b/web/cypress/CYPRESS_TESTING_GUIDE.md
@@ -67,6 +67,7 @@ The Monitoring Plugin uses a 3-layer architecture for test organization:
```
cypress/
+├── component/ # Component tests (isolated, no cluster needed)
├── e2e/
│ ├── monitoring/ # Core monitoring tests (Administrator)
│ │ ├── 00.bvt_admin.cy.ts
@@ -81,7 +82,9 @@ cypress/
│ │ ├── 02.reg_metrics.cy.ts
│ │ └── 03.reg_legacy_dashboards.cy.ts
│ ├── perses/ # COO/Perses scenarios
-│ └── commands/ # Custom Cypress commands
+│ ├── commands/ # Custom Cypress commands
+│ ├── component.ts # Component test support (mount command)
+│ └── component-index.html # HTML template for component mounting
└── views/ # Page object models (reusable actions)
```
@@ -92,7 +95,65 @@ cypress/
---
-## Creating Tests
+## Component Testing
+
+Component tests mount individual React components in isolation using Cypress, without requiring a running OpenShift cluster. They provide fast feedback for rendering logic, props handling, and interactions.
+
+### When to Use Component Tests vs E2E Tests
+
+| Use Component Tests When | Use E2E Tests When |
+|---|---|
+| Testing rendering and visual output | Testing full user workflows |
+| Verifying props and conditional display | Testing navigation between pages |
+| Validating empty/error states | Testing API integration |
+| Fast feedback during development | Testing cross-component interactions |
+
+### Writing Component Tests
+
+Component test files use the `.cy.tsx` extension and live in `cypress/component/`:
+
+```typescript
+import React from 'react';
+import { Labels } from '../../src/components/labels';
+
+describe('Labels', () => {
+ it('renders "No labels" when labels is empty', () => {
+ cy.mount();
+ cy.contains('No labels').should('be.visible');
+ });
+
+ it('renders a single label', () => {
+ cy.mount();
+ cy.contains('app').should('be.visible');
+ cy.contains('monitoring').should('be.visible');
+ });
+});
+```
+
+### Running Component Tests
+
+```bash
+cd web
+
+# Interactive mode (GUI) - best for development
+npm run cypress:open:component
+
+# Headless mode - best for CI
+npm run cypress:run:component
+
+# Run a single component test file
+npx cypress run --component --spec cypress/component/labels.cy.tsx
+```
+
+### Key Differences from E2E Tests
+
+- **No cluster required**: Components are mounted directly in the browser
+- **Custom mount command**: Use `cy.mount()` instead of `cy.visit()`
+- **Support file**: Uses `cypress/support/component.ts` (not `cypress/support/index.ts`)
+
+---
+
+## Creating E2E Tests
### Workflow
@@ -133,11 +194,12 @@ export const runAlertTests = (perspective: string) => {
| Scenario | Action |
|----------|--------|
-| New UI feature | Create new test scenario in support/ |
+| New UI feature | Create new E2E test scenario in support/ |
| Bug fix | Add test case to existing support file |
| Component update | Update existing test scenarios |
-| New Perses feature | Create new test scenario in support/ |
-| ACM integration | Add test in e2e/coo/ |
+| New Perses feature | Create new E2E test scenario in support/ |
+| ACM integration | Add E2E test in e2e/coo/ |
+| Isolated component logic | Add component test in component/ |
### Best Practices
diff --git a/web/cypress/README.md b/web/cypress/README.md
index 9e0c0fc87..2c0e5e4a6 100644
--- a/web/cypress/README.md
+++ b/web/cypress/README.md
@@ -407,20 +407,34 @@ export CYPRESS_SESSION=true
---
+## Component Testing
+
+Cypress component tests mount individual React components in isolation, without a running cluster. They provide fast feedback for testing rendering logic, props handling, and user interactions. See **[CYPRESS_TESTING_GUIDE.md](CYPRESS_TESTING_GUIDE.md)** for more guidance on how
+to write and run the tests.
+
+### Configuration
+
+Component testing is configured in the `component` section of `web/cypress.config.ts`. It uses a standalone webpack config with `swc-loader` and a custom `mount` command (compatible with React 17) defined in `cypress/support/component.ts`.
+
+---
+
## Test Organization
### Directory Structure
```
cypress/
-├── e2e/ # Test files by perspective
+├── component/ # Component test files (.cy.tsx)
+├── e2e/ # E2E test files by perspective
│ ├── monitoring/ # Core monitoring (Administrator)
│ ├── coo/ # COO-specific tests
│ └── virtualization/ # Virtualization integration
├── support/ # Reusable test scenarios
│ ├── monitoring/ # Test scenario modules
│ ├── perses/ # Perses scenarios
-│ └── commands/ # Custom Cypress commands
+│ ├── commands/ # Custom Cypress commands
+│ ├── component.ts # Component test support
+│ └── component-index.html # Component test HTML template
├── views/ # Page object models
├── fixtures/ # Test data and mocks
└── E2E_TEST_SCENARIOS.md # Complete test catalog
diff --git a/web/cypress/component/labels.cy.tsx b/web/cypress/component/labels.cy.tsx
new file mode 100644
index 000000000..fbe7009c1
--- /dev/null
+++ b/web/cypress/component/labels.cy.tsx
@@ -0,0 +1,44 @@
+import { Labels } from '../../src/components/labels';
+
+describe('Labels', () => {
+ it('renders "No labels" when labels is empty', () => {
+ cy.mount();
+ cy.contains('No labels').should('be.visible');
+ });
+
+ it('renders "No labels" when labels is undefined', () => {
+ cy.mount();
+ cy.contains('No labels').should('be.visible');
+ });
+
+ it('renders a single label', () => {
+ cy.mount();
+ cy.contains('app').should('be.visible');
+ cy.contains('monitoring').should('be.visible');
+ });
+
+ it('renders multiple labels', () => {
+ const labels = {
+ app: 'monitoring',
+ env: 'production',
+ team: 'platform',
+ };
+ cy.mount();
+
+ cy.contains('app').should('be.visible');
+ cy.contains('monitoring').should('be.visible');
+ cy.contains('env').should('be.visible');
+ cy.contains('production').should('be.visible');
+ cy.contains('team').should('be.visible');
+ cy.contains('platform').should('be.visible');
+ });
+
+ it('renders label with key=value format', () => {
+ cy.mount();
+ cy.get('.pf-v6-c-label').within(() => {
+ cy.contains('severity');
+ cy.contains('=');
+ cy.contains('critical');
+ });
+ });
+});
diff --git a/web/cypress/component/mocks/AddToDashboardButton.tsx b/web/cypress/component/mocks/AddToDashboardButton.tsx
new file mode 100644
index 000000000..6f0a17276
--- /dev/null
+++ b/web/cypress/component/mocks/AddToDashboardButton.tsx
@@ -0,0 +1,8 @@
+export const AddToDashboardButton = ({ query, name, description }) => (
+
+);
diff --git a/web/cypress/component/mocks/OlsToolUIPersesWrapper.tsx b/web/cypress/component/mocks/OlsToolUIPersesWrapper.tsx
new file mode 100644
index 000000000..0916d59ed
--- /dev/null
+++ b/web/cypress/component/mocks/OlsToolUIPersesWrapper.tsx
@@ -0,0 +1,7 @@
+import React from 'react';
+
+export const OlsToolUIPersesWrapper = ({ children, initialTimeRange }) => (
+
+ {children}
+
+);
diff --git a/web/cypress/component/mocks/perses-dashboards.tsx b/web/cypress/component/mocks/perses-dashboards.tsx
new file mode 100644
index 000000000..8c376e7de
--- /dev/null
+++ b/web/cypress/component/mocks/perses-dashboards.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+
+export const Panel = ({ definition, panelOptions }) => (
+
+
+ {panelOptions?.extra &&
{panelOptions.extra()}
}
+
+);
+
+export const VariableProvider = ({ children }) => <>{children}>;
diff --git a/web/cypress/component/mocks/perses-plugin-system.tsx b/web/cypress/component/mocks/perses-plugin-system.tsx
new file mode 100644
index 000000000..11a73f75f
--- /dev/null
+++ b/web/cypress/component/mocks/perses-plugin-system.tsx
@@ -0,0 +1,2 @@
+export const DataQueriesProvider = ({ children }) => children;
+export const TimeRangeProviderBasic = ({ children }) => children;
diff --git a/web/cypress/component/mocks/perses-prometheus-plugin.ts b/web/cypress/component/mocks/perses-prometheus-plugin.ts
new file mode 100644
index 000000000..54ad650ef
--- /dev/null
+++ b/web/cypress/component/mocks/perses-prometheus-plugin.ts
@@ -0,0 +1,2 @@
+// DurationString is used as a type-only import, so no runtime export needed
+export type DurationString = string;
diff --git a/web/cypress/component/ols-tool-ui/ShowTimeseries.cy.tsx b/web/cypress/component/ols-tool-ui/ShowTimeseries.cy.tsx
new file mode 100644
index 000000000..95b5c0683
--- /dev/null
+++ b/web/cypress/component/ols-tool-ui/ShowTimeseries.cy.tsx
@@ -0,0 +1,111 @@
+import { ShowTimeseries } from '../../../src/components/ols-tool-ui/ShowTimeseries';
+
+describe('ShowTimeseries', () => {
+ const tool = {
+ args: {
+ title: 'CPU Usage',
+ query: 'rate(process_cpu_seconds_total[5m])',
+ description: 'CPU usage over time',
+ },
+ };
+
+ it('renders the panel with correct title and description', () => {
+ cy.mount();
+ cy.get('[data-testid="panel-definition"]')
+ .invoke('attr', 'data-definition')
+ .then((json) => {
+ const definition = JSON.parse(json as string);
+ expect(definition.spec.display.name).to.eq('CPU Usage');
+ expect(definition.spec.display.description).to.contain('CPU usage over time');
+ expect(definition.spec.display.description).to.contain('Query:');
+ });
+ });
+
+ it('renders the AddToDashboardButton with correct props', () => {
+ cy.mount();
+ cy.get('[data-testid="add-to-dashboard"]')
+ .should('have.attr', 'data-name', 'CPU Usage')
+ .and('have.attr', 'data-query', 'rate(process_cpu_seconds_total[5m])')
+ .and('have.attr', 'data-description', 'CPU usage over time');
+ });
+
+ it('wraps content in the Perses wrapper', () => {
+ cy.mount();
+ cy.get('[data-testid="perses-wrapper"]').should('exist');
+ cy.get('[data-testid="mock-panel"]').should('exist');
+ });
+
+ const relativeTimeRangeCases = [
+ {
+ name: 'defaults to 1h when no time args provided',
+ timeArgs: {},
+ expected: { pastDuration: '1h' },
+ },
+ {
+ name: 'uses custom duration',
+ timeArgs: { duration: '30m' },
+ expected: { pastDuration: '30m' },
+ },
+ {
+ name: 'treats end=NOW without start as relative',
+ timeArgs: { end: 'NOW', duration: '2h' },
+ expected: { pastDuration: '2h' },
+ },
+ ];
+
+ relativeTimeRangeCases.forEach(({ name, timeArgs, expected }) => {
+ it(`relative time range: ${name}`, () => {
+ cy.mount();
+ cy.get('[data-testid="perses-wrapper"]')
+ .invoke('attr', 'data-time-range')
+ .then((json) => {
+ expect(JSON.parse(json as string)).to.deep.eq(expected);
+ });
+ });
+ });
+
+ const absoluteTimeRangeCases = [
+ {
+ name: 'ISO start and end',
+ timeArgs: { start: '2024-01-01T00:00:00Z', end: '2024-01-02T00:00:00Z', duration: '1h' },
+ expected: { startISO: '2024-01-01T00:00:00.000Z', endISO: '2024-01-02T00:00:00.000Z' },
+ },
+ {
+ name: 'ISO start with end=NOW',
+ timeArgs: { start: '2024-01-01T00:00:00Z', end: 'NOW' },
+ expected: { startISO: '2024-01-01T00:00:00.000Z', endOffsetMs: 0 },
+ },
+ {
+ name: 'NOW-offset expressions (NOW-48h to NOW-24h)',
+ timeArgs: { start: 'NOW-48h', end: 'NOW-24h' },
+ expected: { startOffsetMs: 48 * 3600 * 1000, endOffsetMs: 24 * 3600 * 1000 },
+ },
+ {
+ name: 'compound Prometheus-style duration (NOW-1d2h)',
+ timeArgs: { start: 'NOW-1d2h', end: 'NOW' },
+ expected: { startOffsetMs: (24 + 2) * 3600 * 1000, endOffsetMs: 0 },
+ },
+ ];
+
+ absoluteTimeRangeCases.forEach(({ name, timeArgs, expected }) => {
+ it(`absolute time range: ${name}`, () => {
+ const now = Date.now();
+ cy.mount();
+ cy.get('[data-testid="perses-wrapper"]')
+ .invoke('attr', 'data-time-range')
+ .then((json) => {
+ const timeRange = JSON.parse(json as string);
+ if (expected.startISO) {
+ expect(timeRange.start).to.eq(expected.startISO);
+ } else {
+ expect(new Date(timeRange.start).getTime()).to.be.closeTo(now - expected.startOffsetMs, 5000);
+ }
+ if (expected.endISO) {
+ expect(timeRange.end).to.eq(expected.endISO);
+ } else {
+ expect(new Date(timeRange.end).getTime()).to.be.closeTo(now - expected.endOffsetMs, 5000);
+ }
+ });
+ });
+ });
+});
diff --git a/web/cypress/support/component-index.html b/web/cypress/support/component-index.html
new file mode 100644
index 000000000..538cb8013
--- /dev/null
+++ b/web/cypress/support/component-index.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/web/cypress/support/component.ts b/web/cypress/support/component.ts
new file mode 100644
index 000000000..a5d7833f2
--- /dev/null
+++ b/web/cypress/support/component.ts
@@ -0,0 +1,35 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import '@patternfly/react-core/dist/styles/base.css';
+
+function mount(jsx: React.ReactElement) {
+ const root = document.querySelector('[data-cy-root]');
+ return cy.then(() => {
+ ReactDOM.render(jsx, root);
+ });
+}
+
+declare global {
+ namespace Cypress {
+ interface Chainable {
+ mount: typeof mount;
+ }
+ }
+}
+
+Cypress.Commands.add('mount', mount);
+
+// Mock react-i18next for all component tests: returns the key with {{interpolations}} replaced
+const mockT = (key: string, opts?: Record) => {
+ if (opts) {
+ return Object.entries(opts).reduce(
+ (result, [k, v]) => result.replace(`{{${k}}}`, v),
+ key,
+ );
+ }
+ return key;
+};
+
+beforeEach(() => {
+ cy.stub(require('react-i18next'), 'useTranslation').returns({ t: mockT });
+});
diff --git a/web/locales/en/plugin__monitoring-plugin.json b/web/locales/en/plugin__monitoring-plugin.json
index de00f6f98..4356b4e90 100644
--- a/web/locales/en/plugin__monitoring-plugin.json
+++ b/web/locales/en/plugin__monitoring-plugin.json
@@ -166,21 +166,63 @@
"Time range": "Time range",
"Refresh interval": "Refresh interval",
"Could not parse JSON data for dashboard \"{{dashboard}}\"": "Could not parse JSON data for dashboard \"{{dashboard}}\"",
- "Dashboard Variables": "Dashboard Variables",
+ "Rename Dashboard": "Rename Dashboard",
+ "Dashboard name": "Dashboard name",
+ "Renaming...": "Renaming...",
+ "Rename": "Rename",
+ "Select project": "Select project",
+ "Loading...": "Loading...",
+ "Select namespace": "Select namespace",
+ "Duplicate": "Duplicate",
+ "this dashboard": "this dashboard",
+ "Permanently delete dashboard?": "Permanently delete dashboard?",
+ "Are you sure you want to delete ": "Are you sure you want to delete ",
+ "? This action can not be undone.": "? This action can not be undone.",
+ "Deleting...": "Deleting...",
+ "Delete": "Delete",
+ "Must be 75 or fewer characters long": "Must be 75 or fewer characters long",
+ "Dashboard name '{{dashboardName}}' already exists in '{{projectName}}' project!": "Dashboard name '{{dashboardName}}' already exists in '{{projectName}}' project!",
+ "Project is required": "Project is required",
+ "Dashboard name is required": "Dashboard name is required",
+ "Failed to create dashboard. Please try again.": "Failed to create dashboard. Please try again.",
+ "Create": "Create",
+ "Create Dashboard": "Create Dashboard",
+ "my-new-dashboard": "my-new-dashboard",
+ "Creating...": "Creating...",
+ "View and manage dashboards.": "View and manage dashboards.",
+ "Rename dashboard": "Rename dashboard",
+ "Duplicate dashboard": "Duplicate dashboard",
+ "Delete dashboard": "Delete dashboard",
+ "You don't have permissions to dashboard actions": "You don't have permissions to dashboard actions",
+ "Dashboard": "Dashboard",
+ "Project": "Project",
+ "Created on": "Created on",
+ "Last Modified": "Last Modified",
+ "Filter by name": "Filter by name",
+ "Filter by project": "Filter by project",
+ "No dashboards found": "No dashboards found",
+ "No results match the filter criteria. Clear filters to show results.": "No results match the filter criteria. Clear filters to show results.",
+ "No Perses dashboards are currently available in this project.": "No Perses dashboards are currently available in this project.",
+ "Clear all filters": "Clear all filters",
+ "Dashboard not found": "Dashboard not found",
+ "The dashboard \"{{name}}\" was not found in project \"{{project}}\".": "The dashboard \"{{name}}\" was not found in project \"{{project}}\".",
+ "Empty Dashboard": "Empty Dashboard",
+ "To get started add something to your dashboard": "To get started add something to your dashboard",
+ "Edit": "Edit",
+ "You don't have permission to edit this dashboard": "You don't have permission to edit this dashboard",
"No matching datasource found": "No matching datasource found",
"No Dashboard Available in Selected Project": "No Dashboard Available in Selected Project",
"To explore data, create a dashboard for this project": "To explore data, create a dashboard for this project",
"No Perses Project Available": "No Perses Project Available",
"To explore data, create a Perses Project": "To explore data, create a Perses Project",
- "Empty Dashboard": "Empty Dashboard",
- "To get started add something to your dashboard": "To get started add something to your dashboard",
+ "Project is required for fetching project dashboards": "Project is required for fetching project dashboards",
"No projects found": "No projects found",
"No results match the filter criteria.": "No results match the filter criteria.",
"Clear filters": "Clear filters",
"Select project...": "Select project...",
"Projects": "Projects",
- "Project": "Project",
- "Dashboard": "Dashboard",
+ "All Projects": "All Projects",
+ "useToast must be used within ToastProvider": "useToast must be used within ToastProvider",
"Refresh off": "Refresh off",
"{{count}} second_one": "{{count}} second",
"{{count}} second_other": "{{count}} seconds",
@@ -203,7 +245,7 @@
"Component(s)": "Component(s)",
"Alert": "Alert",
"Incidents": "Incidents",
- "Clear all filters": "Clear all filters",
+ "Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information.": "Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information.",
"Filter type selection": "Filter type selection",
"Incident ID": "Incident ID",
"Severity filters": "Severity filters",
@@ -264,6 +306,8 @@
"No Units": "No Units",
"Metrics": "Metrics",
"This dropdown only formats results.": "This dropdown only formats results.",
+ "Add To Dashboard": "Add To Dashboard",
+ "Query: {{query}}": "Query: {{query}}",
"graph timespan": "graph timespan",
"Reset zoom": "Reset zoom",
"Displaying with reduced resolution due to large dataset.": "Displaying with reduced resolution due to large dataset.",
@@ -303,6 +347,5 @@
"No metrics targets found": "No metrics targets found",
"Error loading latest targets data": "Error loading latest targets data",
"Search by endpoint or namespace...": "Search by endpoint or namespace...",
- "Text": "Text",
- "Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information.":"Incident data is updated every few minutes. What you see may be up to 5 minutes old. Refresh the page to view updated information."
+ "Text": "Text"
}
\ No newline at end of file
diff --git a/web/package.json b/web/package.json
index f6906b0e5..de38716df 100644
--- a/web/package.json
+++ b/web/package.json
@@ -9,7 +9,9 @@
"build:standalone": "npm run clean && npm run ts-node node_modules/.bin/webpack --config webpack.standalone.config.ts",
"clean": "rm -rf dist",
"cypress:open": "cypress open",
+ "cypress:open:component": "cypress open --component",
"cypress:run": "cypress run",
+ "cypress:run:component": "cypress run --component",
"cypress:run:ci": "NO_COLOR=1 npx cypress run --browser chrome",
"delete:reports": "rm -rf gui_test_screenshots/* || true",
"i18n": "i18n-scripts/build-i18n.sh && node i18n-scripts/set-english-defaults.js",
@@ -182,7 +184,8 @@
"TargetsPage": "./components/targets-page",
"PrometheusRedirectPage": "./components/redirects/prometheus-redirect-page",
"DevRedirects": "./components/redirects/dev-redirects",
- "MonitoringContext": "./contexts/MonitoringContext"
+ "MonitoringContext": "./contexts/MonitoringContext",
+ "ols-tool-ui": "./components/ols-tool-ui"
},
"dependencies": {
"@console/pluginAPI": "*"
diff --git a/web/src/components/dashboards/perses/PersesWrapper.tsx b/web/src/components/dashboards/perses/PersesWrapper.tsx
index 47589122a..0491ea382 100644
--- a/web/src/components/dashboards/perses/PersesWrapper.tsx
+++ b/web/src/components/dashboards/perses/PersesWrapper.tsx
@@ -343,7 +343,6 @@ export function useRemotePluginLoader(): PluginLoader {
export function PersesWrapper({ children, project }: PersesWrapperProps) {
const { theme } = usePatternFlyTheme();
- const [dashboardName] = useQueryParam(QueryParams.Dashboard, StringParam);
const muiTheme = getTheme(theme, {
shape: {
borderRadius: 6,
@@ -371,13 +370,7 @@ export function PersesWrapper({ children, project }: PersesWrapperProps) {
variant="default"
>
- {!project ? (
- <>{children}>
- ) : (
-
- {children}
-
- )}
+ {!project ? <>{children}> : {children}}
@@ -385,7 +378,8 @@ export function PersesWrapper({ children, project }: PersesWrapperProps) {
);
}
-function InnerWrapper({ children, project, dashboardName }) {
+function InnerWrapper({ children, project }) {
+ const [dashboardName] = useQueryParam(QueryParams.Dashboard, StringParam);
const { data } = usePluginBuiltinVariableDefinitions();
const { persesDashboard, persesDashboardLoading } = useFetchPersesDashboard(
project,
diff --git a/web/src/components/dashboards/perses/dashboard-app.tsx b/web/src/components/dashboards/perses/dashboard-app.tsx
index 62b9e4616..a7981ea2c 100644
--- a/web/src/components/dashboards/perses/dashboard-app.tsx
+++ b/web/src/components/dashboards/perses/dashboard-app.tsx
@@ -24,11 +24,13 @@ import {
useDiscardChangesConfirmationDialog,
useEditMode,
} from '@perses-dev/dashboards';
+
import { OCPDashboardToolbar } from './dashboard-toolbar';
import { useUpdateDashboardMutation } from './dashboard-api';
import { useTranslation } from 'react-i18next';
import { useToast } from './ToastProvider';
import { useSearchParams } from 'react-router-dom-v5-compat';
+import { useExternalPanelAddition } from './useExternalPanelAddition';
export interface DashboardAppProps {
dashboardResource: DashboardResource | EphemeralDashboardResource;
@@ -124,6 +126,8 @@ export const OCPDashboardApp = (props: DashboardAppProps): ReactElement => {
}
};
+ useExternalPanelAddition({ isEditMode, onEditButtonClick });
+
const updateDashboardMutation = useUpdateDashboardMutation();
const onSave = useCallback(
diff --git a/web/src/components/dashboards/perses/useExternalPanelAddition.ts b/web/src/components/dashboards/perses/useExternalPanelAddition.ts
new file mode 100644
index 000000000..81b4e66fa
--- /dev/null
+++ b/web/src/components/dashboards/perses/useExternalPanelAddition.ts
@@ -0,0 +1,77 @@
+import { useEffect, useState, useCallback } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useDashboardActions, useDashboardStore } from '@perses-dev/dashboards';
+import { dashboardsOpened, dashboardsPersesPanelExternallyAdded } from '../../../store/actions';
+
+interface UseExternalPanelAdditionOptions {
+ isEditMode: boolean;
+ onEditButtonClick: () => void;
+}
+
+export function useExternalPanelAddition({
+ isEditMode,
+ onEditButtonClick,
+}: UseExternalPanelAdditionOptions) {
+ const dispatch = useDispatch();
+ const addPersesPanelExternally: any = useSelector(
+ (s: any) => s.plugins?.mp?.dashboards?.addPersesPanelExternally,
+ );
+ const { openAddPanel } = useDashboardActions();
+ const dashboardStore = useDashboardStore();
+ const [externallyAddedPanel, setExternallyAddedPanel] = useState(null);
+
+ const addPanelExternally = useCallback(
+ (panelDefinition: any): void => {
+ // Simulate opening a panel to add the pane so that we can use it to programatically
+ // add a panel to the dashboard from an external source (AI assistant).
+ if (!isEditMode) {
+ onEditButtonClick();
+ }
+ openAddPanel();
+ // Wrap the panelDefinition with the groupId structure
+ const change = {
+ groupId: 0,
+ panelDefinition,
+ };
+ setExternallyAddedPanel(change);
+ },
+ [isEditMode, onEditButtonClick, openAddPanel],
+ );
+
+ useEffect(() => {
+ // Listen for external panel addition requests
+ if (addPersesPanelExternally) {
+ addPanelExternally(addPersesPanelExternally);
+ dispatch(dashboardsPersesPanelExternallyAdded());
+ }
+
+ // Apply externally added panel
+ if (externallyAddedPanel) {
+ const groupId = dashboardStore.panelGroupOrder[0];
+ externallyAddedPanel.groupId = groupId;
+
+ // Use the temporary panelEditor to add changes to the dashboard.
+ const panelEditor = dashboardStore.panelEditor;
+ panelEditor.applyChanges(externallyAddedPanel);
+ panelEditor.close();
+
+ // Clear the externally added panel after applying changes
+ setExternallyAddedPanel(null);
+ }
+ }, [
+ dispatch,
+ dashboardStore.panelGroupOrder,
+ dashboardStore.panelEditor,
+ externallyAddedPanel,
+ addPanelExternally,
+ addPersesPanelExternally,
+ ]);
+
+ // Advertise when custom dashboard is opened/closed
+ useEffect(() => {
+ dispatch(dashboardsOpened(true));
+ return () => {
+ dispatch(dashboardsOpened(false));
+ };
+ }, [dispatch]);
+}
diff --git a/web/src/components/ols-tool-ui/ShowTimeseries.tsx b/web/src/components/ols-tool-ui/ShowTimeseries.tsx
new file mode 100644
index 000000000..76a766d4c
--- /dev/null
+++ b/web/src/components/ols-tool-ui/ShowTimeseries.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { DataQueriesProvider } from '@perses-dev/plugin-system';
+import { Panel } from '@perses-dev/dashboards';
+
+import { OlsToolUIPersesWrapper } from './helpers/OlsToolUIPersesWrapper';
+import { AddToDashboardButton } from './helpers/AddToDashboardButton';
+import { useTimeRange } from './helpers/useTimeRange';
+
+type ExecuteRangeQueryTool = {
+ args: {
+ title: string;
+ description: string;
+ query: string;
+ start?: string;
+ end?: string;
+ duration?: string;
+ };
+};
+
+export const ShowTimeseries: React.FC<{ tool: ExecuteRangeQueryTool }> = ({ tool }) => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+ const { query, title, description, start, end, duration } = tool.args;
+ const timeRange = useTimeRange(start, end, duration);
+ const queryDescription = t('Query: {{query}}', { query: query });
+ const definitions = [
+ {
+ kind: 'PrometheusTimeSeriesQuery',
+ spec: {
+ query: query,
+ },
+ },
+ ];
+
+ return (
+ <>
+
+
+ (
+
+ ),
+ }}
+ definition={{
+ kind: 'Panel',
+ spec: {
+ queries: [],
+ display: { name: title, description: `${description}\n\n${queryDescription}` },
+ plugin: {
+ kind: 'TimeSeriesChart',
+ spec: {
+ legend: {
+ position: 'bottom',
+ },
+ },
+ },
+ },
+ }}
+ />
+
+
+ >
+ );
+};
+
+export default ShowTimeseries;
diff --git a/web/src/components/ols-tool-ui/helpers/AddToDashboardButton.tsx b/web/src/components/ols-tool-ui/helpers/AddToDashboardButton.tsx
new file mode 100644
index 000000000..807663acb
--- /dev/null
+++ b/web/src/components/ols-tool-ui/helpers/AddToDashboardButton.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useDispatch, useSelector } from 'react-redux';
+import { IconButton, IconButtonProps, styled } from '@mui/material';
+import { Theme } from '@mui/material/styles';
+import ViewGridPlusIcon from 'mdi-material-ui/ViewGridPlus';
+import { StyledComponent } from '@emotion/styled';
+
+import type { PanelDefinition } from '@perses-dev/core';
+import { InfoTooltip } from '@perses-dev/components';
+
+import { dashboardsAddPersesPanelExternally } from '../../../store/actions';
+
+export const HeaderIconButton: StyledComponent = styled(
+ IconButton,
+)(({ theme }) => ({
+ borderRadius: theme.shape.borderRadius,
+ padding: '4px',
+}));
+
+function createPanelDefinition(query: string, name: string, description: string): PanelDefinition {
+ return {
+ kind: 'Panel',
+ spec: {
+ display: {
+ name: name,
+ description: description,
+ },
+ plugin: {
+ kind: 'TimeSeriesChart',
+ spec: {},
+ },
+ queries: [
+ {
+ kind: 'TimeSeriesQuery',
+ spec: {
+ plugin: {
+ kind: 'PrometheusTimeSeriesQuery',
+ spec: {
+ query: query,
+ },
+ },
+ },
+ },
+ ],
+ },
+ };
+}
+
+type AddToDashboardButtonProps = {
+ query: string;
+ name?: string;
+ description?: string;
+};
+
+export const AddToDashboardButton: React.FC = ({
+ query,
+ name,
+ description,
+}) => {
+ const dispatch = useDispatch();
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+ const isCustomDashboardOpen: boolean = useSelector(
+ (s: any) => s.plugins?.mp?.dashboards?.isOpened,
+ );
+ const addToPersesDashboard = React.useCallback(() => {
+ const panelDefinition = createPanelDefinition(query, name, description);
+ dispatch(dashboardsAddPersesPanelExternally(panelDefinition));
+ }, [query, name, description, dispatch]);
+
+ if (!isCustomDashboardOpen) {
+ // No dashboard is opened - nothing to add to.
+ return null;
+ }
+
+ return (
+
+
+ theme.palette.text.secondary }}
+ onClick={addToPersesDashboard}
+ />
+
+
+ );
+};
diff --git a/web/src/components/ols-tool-ui/helpers/OlsToolUIPersesWrapper.tsx b/web/src/components/ols-tool-ui/helpers/OlsToolUIPersesWrapper.tsx
new file mode 100644
index 000000000..294e7252a
--- /dev/null
+++ b/web/src/components/ols-tool-ui/helpers/OlsToolUIPersesWrapper.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { VariableProvider } from '@perses-dev/dashboards';
+import { TimeRangeProviderBasic } from '@perses-dev/plugin-system';
+import type { DurationString, TimeRangeValue } from '@perses-dev/core';
+
+import {
+ PersesWrapper,
+ PersesPrometheusDatasourceWrapper,
+} from '../../dashboards/perses/PersesWrapper';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
+
+interface OlsToolUIPersesWrapperProps {
+ children: React.ReactNode;
+ height?: string;
+ initialTimeRange?: TimeRangeValue;
+}
+
+export const OlsToolUIPersesWrapper: React.FC = ({
+ children,
+ initialTimeRange = { pastDuration: '1h' as DurationString },
+ height = '300px',
+}) => {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+};
diff --git a/web/src/components/ols-tool-ui/helpers/useTimeRange.ts b/web/src/components/ols-tool-ui/helpers/useTimeRange.ts
new file mode 100644
index 000000000..f9e8f2270
--- /dev/null
+++ b/web/src/components/ols-tool-ui/helpers/useTimeRange.ts
@@ -0,0 +1,38 @@
+import { useMemo } from 'react';
+import { sub } from 'date-fns';
+import type { AbsoluteTimeRange, RelativeTimeRange, TimeRangeValue } from '@perses-dev/core';
+import { isDurationString, parseDurationString } from '@perses-dev/core';
+
+// Parse a Prometheus-style time expression like "NOW", "NOW-48h", "NOW-1d2h30m",
+// or a plain date string like "2024-01-01T00:00:00Z".
+const parseTimeExpr = (expr: string, now: Date): Date | undefined => {
+ const match = expr.match(/^NOW(?:-(.+))?$/);
+ if (!match) {
+ return new Date(expr);
+ }
+ if (!match[1]) {
+ return now;
+ }
+ if (isDurationString(match[1])) {
+ return sub(now, parseDurationString(match[1]));
+ }
+ return undefined;
+};
+
+export const useTimeRange = (start?: string, end?: string, duration?: string): TimeRangeValue => {
+ return useMemo(() => {
+ const now = new Date();
+ const startDate = start ? parseTimeExpr(start, now) : undefined;
+ const endDate = end ? parseTimeExpr(end, now) : undefined;
+
+ // If end is exactly "NOW" with no offset and no explicit start, use relative time range
+ if (end === 'NOW' && !start) {
+ return { pastDuration: duration || '1h' } as RelativeTimeRange;
+ }
+
+ if (startDate && endDate) {
+ return { start: startDate, end: endDate } as AbsoluteTimeRange;
+ }
+ return { pastDuration: duration || '1h' } as RelativeTimeRange;
+ }, [duration, end, start]);
+};
diff --git a/web/src/components/ols-tool-ui/index.ts b/web/src/components/ols-tool-ui/index.ts
new file mode 100644
index 000000000..27dc949d5
--- /dev/null
+++ b/web/src/components/ols-tool-ui/index.ts
@@ -0,0 +1 @@
+export { ShowTimeseries } from './ShowTimeseries';
diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts
index ef45c45cf..2169b1193 100644
--- a/web/src/store/actions.ts
+++ b/web/src/store/actions.ts
@@ -1,3 +1,4 @@
+import type { PanelDefinition } from '@perses-dev/core';
import { action, ActionType as Action } from 'typesafe-actions';
import { Alert, Rule, Silence } from '@openshift-console/dynamic-plugin-sdk';
@@ -17,6 +18,9 @@ export enum ActionType {
DashboardsSetPollInterval = 'v2/dashboardsSetPollInterval',
DashboardsSetTimespan = 'v2/dashboardsSetTimespan',
DashboardsVariableOptionsLoaded = 'v2/dashboardsVariableOptionsLoaded',
+ DashboardsOpened = 'dashboardsPersesDashboardsOpened',
+ DashboardsAddPersesPanelExternally = 'dashboardsAddPersesPanelExternally',
+ DashboardsPersesPanelExternallyAdded = 'dashboardsPersesPanelExternallyAdded',
QueryBrowserAddQuery = 'queryBrowserAddQuery',
QueryBrowserDuplicateQuery = 'queryBrowserDuplicateQuery',
QueryBrowserDeleteAllQueries = 'queryBrowserDeleteAllQueries',
@@ -68,6 +72,15 @@ export const dashboardsSetTimespan = (timespan: number) =>
export const dashboardsVariableOptionsLoaded = (key: string, newOptions: string[]) =>
action(ActionType.DashboardsVariableOptionsLoaded, { key, newOptions });
+export const dashboardsOpened = (isOpened: boolean) =>
+ action(ActionType.DashboardsOpened, { isOpened });
+
+export const dashboardsPersesPanelExternallyAdded = () =>
+ action(ActionType.DashboardsPersesPanelExternallyAdded, {});
+
+export const dashboardsAddPersesPanelExternally = (panelDefinition: PanelDefinition) =>
+ action(ActionType.DashboardsAddPersesPanelExternally, { panelDefinition });
+
export const alertingSetLoading = (datasource: string, identifier: string) =>
action(ActionType.AlertingSetLoading, {
datasource,
diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts
index 852fd2dbe..fe9b7588e 100644
--- a/web/src/store/reducers.ts
+++ b/web/src/store/reducers.ts
@@ -81,6 +81,21 @@ const monitoringReducer = produce((draft: ObserveState, action: ObserveAction):
break;
}
+ case ActionType.DashboardsOpened: {
+ draft.dashboards.isOpened = action.payload.isOpened;
+ break;
+ }
+
+ case ActionType.DashboardsAddPersesPanelExternally: {
+ draft.dashboards.addPersesPanelExternally = action.payload.panelDefinition;
+ break;
+ }
+
+ case ActionType.DashboardsPersesPanelExternallyAdded: {
+ draft.dashboards.addPersesPanelExternally = null;
+ break;
+ }
+
case ActionType.AlertingSetRulesLoaded: {
const { datasource, identifier, rules, alerts } = action.payload;
diff --git a/web/src/store/store.ts b/web/src/store/store.ts
index 9dfd6040e..974a1f4d0 100644
--- a/web/src/store/store.ts
+++ b/web/src/store/store.ts
@@ -1,5 +1,6 @@
import * as _ from 'lodash-es';
+import type { PanelDefinition } from '@perses-dev/core';
import { MONITORING_DASHBOARDS_DEFAULT_TIMESPAN } from '../components/dashboards/legacy/utils';
import { Alert, PrometheusLabels, Rule } from '@openshift-console/dynamic-plugin-sdk';
import { Silences } from '../components/types';
@@ -27,6 +28,8 @@ export type ObserveState = {
pollInterval: number;
timespan: number;
variables: Record;
+ isOpened: boolean;
+ addPersesPanelExternally: PanelDefinition;
};
incidentsData: {
incidents: Array;