diff --git a/Makefile b/Makefile
index 835e2a933..5ffcb8ec5 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@ PLATFORMS ?= linux/arm64,linux/amd64
ORG ?= openshift-observability-ui
PLUGIN_NAME ?=monitoring-plugin
IMAGE ?= quay.io/${ORG}/${PLUGIN_NAME}:${VERSION}
-MONITORING_FEATURES ?=alerting,targets,legacy-dashboards,metrics
+MONITORING_FEATURES ?=alerting,mcp-overview,targets,legacy-dashboards,metrics
ALL_FEATURES ?=$(MONITORING_FEATURES),cluster-health-analyzer,perses-dashboards
MCP_DEVSPACE_FEATURES ?=cluster-health-analyzer,perses-dashboards,acm-alerting
diff --git a/README.md b/README.md
index e86aabf4e..86c7f7ccd 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ This plugin enables frontend UI based on feature flags passed to the backend. Th
Feature flags should be added to the Feature enum [here](pkg/server/server.go) and to the useFeatures hook [here](web/src/shared/hooks/useFeatures.ts). Whenever a feature is enabled, a set of related feature extension points is included in the plugin-manifest.json served by the backend. These feature extension points are created through the use of [json-patches](https://datatracker.ietf.org/doc/html/rfc6902), such as the `acm-alerting` patch [here](config/acm-alerting.patch.json). The server looks for a patch in the format of `{feature-flag-name}.patch.json` to apply. Some feature flags, such as `acm-alerting` require other flags to be set such as `alertmanager` and `thanos-querier` to instruct the backend how to communicate with the observability signals they utilize
| Feature | OCP Version |
-| ----------------- | ----------- |
+|-------------------| ----------- |
| acm-alerting | 4.14+ |
| perses-dashboards | 4.14+ |
| incidents | 4.17+ |
@@ -15,6 +15,7 @@ Feature flags should be added to the Feature enum [here](pkg/server/server.go) a
| legacy-dashboards | 5.0+ |
| metrics | 5.0+ |
| targets | 5.0+ |
+| mcp-overview | 5.0+ |
## monitoring-plugin
@@ -195,7 +196,7 @@ $ make start-console
$ make start-coo-backend
```
-`make start-coo-backend` will inject the `alerting,targets,legacy-dashboards,metrics,incidents,perses-dashboards` features.
+`make start-coo-backend` will inject the `alerting,targets,mcp-overview,legacy-dashboards,metrics,incidents,perses-dashboards` features.
#### Local Development with Perses Proxy
diff --git a/cmd/plugin-backend.go b/cmd/plugin-backend.go
index fc94f8279..fdd193ceb 100644
--- a/cmd/plugin-backend.go
+++ b/cmd/plugin-backend.go
@@ -17,7 +17,7 @@ var (
portArg = flag.Int("port", 9443, "server port to listen on\nports 9444 and 9445 reserved for other use")
certArg = flag.String("cert", "", "cert file path to enable TLS (disabled by default)")
keyArg = flag.String("key", "", "private key file path to enable TLS (disabled by default)")
- featuresArg = flag.String("features", "", "enabled features, comma separated.\noptions: ['acm-alerting', 'alerting', 'incidents', 'legacy-dashboards', 'metrics', 'targets', 'perses-dashboards', 'cluster-health-analyzer']")
+ featuresArg = flag.String("features", "", "enabled features, comma separated.\noptions: ['acm-alerting', 'alerting', 'incidents', 'legacy-dashboards', 'metrics', 'targets', 'mcp-overview', 'perses-dashboards', 'cluster-health-analyzer']")
staticPathArg = flag.String("static-path", "/opt/app-root/web/dist", "static files path to serve frontend")
configPathArg = flag.String("config-path", "/opt/app-root/config", "config files path")
pluginConfigArg = flag.String("plugin-config-path", "/etc/plugin/config.yaml", "plugin yaml configuration")
diff --git a/config/mcp-overview.patch.json b/config/mcp-overview.patch.json
new file mode 100644
index 000000000..d80b3a05c
--- /dev/null
+++ b/config/mcp-overview.patch.json
@@ -0,0 +1,30 @@
+[
+ {
+ "op": "add",
+ "path": "/extensions/0",
+ "value": {
+ "type": "console.navigation/href",
+ "properties": {
+ "id": "mcp-overview",
+ "name": "%plugin__monitoring-plugin~Observability services%",
+ "href": "/monitoring/overview",
+ "perspective": "admin",
+ "section": "observe"
+ }
+ }
+ },
+ {
+ "op": "add",
+ "path": "/extensions/0",
+ "value": {
+ "type": "console.page/route",
+ "properties": {
+ "exact": false,
+ "path": "/monitoring/overview",
+ "component": {
+ "$codeRef": "McpOverviewPage.MpCmoMcpOverviewPage"
+ }
+ }
+ }
+ }
+]
diff --git a/pkg/server/plugin_handler.go b/pkg/server/plugin_handler.go
index 51d4066c6..6bfa04de1 100644
--- a/pkg/server/plugin_handler.go
+++ b/pkg/server/plugin_handler.go
@@ -42,6 +42,7 @@ func patchManifest(baseManifestData []byte, cfg *Config) []byte {
{"metrics.patch.json", features[Metrics]},
{"legacy-dashboards.patch.json", features[LegacyDashboards]},
{"targets.patch.json", features[Targets]},
+ {"mcp-Overview.patch.json", features[McpOverview]},
{"monitoring-console-plugin.patch.json", features[Incidents] || features[ClusterHealthAnalyzer] || features[PersesDashboards] || features[AcmAlerting]},
{"acm-alerting.patch.json", features[AcmAlerting]},
{"cluster-health-analyzer.patch.json", features[Incidents] || features[ClusterHealthAnalyzer]},
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 679781c4e..831a7c497 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -63,6 +63,7 @@ const (
LegacyDashboards Feature = "legacy-dashboards"
Metrics Feature = "metrics"
Targets Feature = "targets"
+ McpOverview Feature = "mcp-overview"
PersesDashboards Feature = "perses-dashboards"
ClusterHealthAnalyzer Feature = "cluster-health-analyzer"
)
diff --git a/web/cypress/component/SummaryCard.cy.tsx b/web/cypress/component/SummaryCard.cy.tsx
new file mode 100644
index 000000000..f766d035b
--- /dev/null
+++ b/web/cypress/component/SummaryCard.cy.tsx
@@ -0,0 +1,76 @@
+import { MemoryRouter } from 'react-router';
+
+import SummaryCard from '@/features/mcp-overview/components/summary/SummaryCard';
+import { DataTestIDs } from '@/shared/constants/data-test';
+
+const mountCard = (props: {
+ count: number;
+ title: string;
+ url: string;
+ cardId: string;
+ loading?: boolean;
+ error?: string;
+}) => {
+ cy.mount(
+
+
+ ,
+ );
+};
+
+describe('SummaryCard', () => {
+ it('renders title and count, and navigates on click', () => {
+ mountCard({
+ cardId: 'metrics',
+ count: 42,
+ title: 'Metrics',
+ url: '/monitoring/query-browser',
+ });
+
+ cy.get(`[data-test="${DataTestIDs.McpOverviewPage.SummaryCard}-metrics"]`).should('be.visible');
+ cy.contains('h3', 'Metrics').should('be.visible');
+ cy.get(`[data-test="${DataTestIDs.McpOverviewPage.SummaryCardCount}-metrics"]`)
+ .should('be.visible')
+ .should('contain.text', '42')
+ .click();
+
+ cy.location('pathname').should('eq', '/monitoring/query-browser');
+ });
+
+ it('renders loading state', () => {
+ mountCard({
+ cardId: 'targets',
+ count: 0,
+ title: 'Targets',
+ url: '/monitoring/targets',
+ loading: true,
+ });
+
+ cy.get(`[data-test="${DataTestIDs.McpOverviewPage.SummaryCardLoading}-targets"]`).should(
+ 'be.visible',
+ );
+ cy.get(`[data-test="${DataTestIDs.McpOverviewPage.SummaryCardCount}-targets"]`).should(
+ 'not.exist',
+ );
+ cy.get(`[data-test="${DataTestIDs.McpOverviewPage.SummaryCardError}-targets"]`).should(
+ 'not.exist',
+ );
+ });
+
+ it('renders error state', () => {
+ mountCard({
+ cardId: 'dashboards',
+ count: 0,
+ title: 'Perses Dashboards',
+ url: '/monitoring/v2/dashboards',
+ error: 'Failed to fetch dashboards',
+ });
+
+ cy.get(`[data-test="${DataTestIDs.McpOverviewPage.SummaryCardError}-dashboards"]`).should(
+ 'be.visible',
+ );
+ cy.get(`[data-test="${DataTestIDs.McpOverviewPage.SummaryCardCount}-dashboards"]`).should(
+ 'not.exist',
+ );
+ });
+});
diff --git a/web/cypress/e2e/monitoring/00.bvt_admin.cy.ts b/web/cypress/e2e/monitoring/00.bvt_admin.cy.ts
index b082cab6b..8f60b1273 100644
--- a/web/cypress/e2e/monitoring/00.bvt_admin.cy.ts
+++ b/web/cypress/e2e/monitoring/00.bvt_admin.cy.ts
@@ -2,7 +2,7 @@ import { nav } from '../../views/nav';
import { alerts } from '../../fixtures/monitoring/alert';
import { runBVTMonitoringTests } from '../../support/monitoring/00.bvt_monitoring.cy';
import { commonPages } from '../../views/common';
-import { overviewPage } from '../../views/overview-page';
+import { mcpOverviewPage } from '../../views/overview-page';
// Set constants for the operators that need to be installed for tests.
const MP = {
namespace: 'openshift-monitoring',
@@ -28,6 +28,8 @@ describe('BVT: Monitoring', { tags: ['@smoke', '@monitoring'] }, () => {
cy.log(`Admin perspective - Observe Menu and verify all submenus`);
nav.sidenav.clickNavLink(['Administration', 'Cluster Settings']);
commonPages.detailsPage.administration_clusterSettings();
+ nav.sidenav.clickNavLink(['Observe', 'Observability services']);
+ commonPages.titleShouldHaveText('Observability services');
nav.sidenav.clickNavLink(['Observe', 'Alerting']);
commonPages.titleShouldHaveText('Alerting');
nav.tabs.switchTab('Silences');
@@ -50,7 +52,7 @@ describe('BVT: Monitoring', { tags: ['@smoke', '@monitoring'] }, () => {
it(`2. Admin perspective - Overview Page > Status - View alerts`, () => {
nav.sidenav.clickNavLink(['Home', 'Overview']);
- overviewPage.clickStatusViewAlerts();
+ mcpOverviewPage.clickStatusViewAlerts();
commonPages.titleShouldHaveText('Alerting');
});
@@ -60,13 +62,13 @@ describe('BVT: Monitoring', { tags: ['@smoke', '@monitoring'] }, () => {
// it('3. Admin perspective - Overview Page > Status - View details', () => {
// cy.visit('/');
// nav.sidenav.clickNavLink(['Home', 'Overview']);
- // overviewPage.clickStatusViewDetails(0);
+ // mcpOverviewPage.clickStatusViewDetails(0);
// detailsPage.sectionHeaderShouldExist('Alert details');
// });
it(`3. Admin perspective - Cluster Utilization - Metrics`, () => {
nav.sidenav.clickNavLink(['Home', 'Overview']);
- overviewPage.clickClusterUtilizationViewCPU();
+ mcpOverviewPage.clickClusterUtilizationViewCPU();
commonPages.titleShouldHaveText('Metrics');
commonPages.projectDropdownShouldExist();
});
diff --git a/web/cypress/e2e/monitoring/regression/04.reg_services_admin.cy.ts b/web/cypress/e2e/monitoring/regression/04.reg_services_admin.cy.ts
new file mode 100644
index 000000000..8a1932af6
--- /dev/null
+++ b/web/cypress/e2e/monitoring/regression/04.reg_services_admin.cy.ts
@@ -0,0 +1,30 @@
+import { runAllRegressionMcpOverviewTests } from '../../../support/monitoring/04.reg_mcp-overview.cy';
+import { commonPages } from '../../../views/common';
+import { nav } from '../../../views/nav';
+import { mcpOverviewPage } from '../../../views/mcp-overview-page';
+
+const MP = {
+ namespace: 'openshift-monitoring',
+ operatorName: 'Cluster Monitoring Operator',
+};
+
+describe(
+ 'Regression: Monitoring - Observability services (Administrator)',
+ { tags: ['@monitoring', '@mcp-overview'] },
+ () => {
+ before(() => {
+ cy.beforeBlock(MP);
+ });
+
+ beforeEach(() => {
+ nav.sidenav.clickNavLink(['Observe', 'Metrics']);
+ commonPages.titleShouldHaveText('Metrics');
+ mcpOverviewPage.clearInfoAlertDismissed();
+ mcpOverviewPage.goTo();
+ });
+
+ runAllRegressionMcpOverviewTests({
+ name: 'Administrator',
+ });
+ },
+);
diff --git a/web/cypress/support/monitoring/04.reg_mcp-overview.cy.ts b/web/cypress/support/monitoring/04.reg_mcp-overview.cy.ts
new file mode 100644
index 000000000..ec7aa97ea
--- /dev/null
+++ b/web/cypress/support/monitoring/04.reg_mcp-overview.cy.ts
@@ -0,0 +1,87 @@
+import { commonPages } from '../../views/common';
+import { mcpOverviewPage, McpOverviewSummaryCardId } from '../../views/mcp-overview-page';
+
+export interface PerspectiveConfig {
+ name: string;
+ beforeEach?: () => void;
+}
+
+const ALL_CARDS: McpOverviewSummaryCardId[] = [
+ 'dashboards',
+ 'alerting-rules',
+ 'firing-alerts',
+ 'targets',
+ 'metrics',
+];
+
+export function runAllRegressionMcpOverviewTests(perspective: PerspectiveConfig) {
+ testMcpOverviewRegression(perspective);
+}
+
+export function testMcpOverviewRegression(perspective: PerspectiveConfig) {
+ it(`${perspective.name} perspective - Observability services page`, () => {
+ cy.log('1.1 Navigate to Observability services and verify page chrome');
+ mcpOverviewPage.clearInfoAlertDismissed();
+ mcpOverviewPage.goTo();
+ mcpOverviewPage.shouldBeLoaded();
+ cy.contains(
+ 'Manage and monitor your metrics, logs, and traces from a single, unified hub.',
+ ).should('be.visible');
+
+ cy.log('1.2 Verify info alert is visible and dismissible with localStorage persistence');
+ mcpOverviewPage.elements.infoAlert().should('be.visible');
+ mcpOverviewPage.elements.infoAlert().should('contain.text', 'Cluster-wide observability scope');
+ mcpOverviewPage.dismissInfoAlert();
+ cy.window()
+ .its('localStorage')
+ .invoke('getItem', 'monitoring/mcp-overview/info-alert-dismissed')
+ .should('eq', 'true');
+
+ cy.log('1.3 Reload and verify dismissed alert stays hidden');
+ mcpOverviewPage.goTo();
+ mcpOverviewPage.elements.infoAlert().should('not.exist');
+ mcpOverviewPage.elements.summarySection().should('be.visible');
+
+ cy.log('1.4 Verify all summary cards finish loading');
+ ALL_CARDS.forEach((cardId) => {
+ mcpOverviewPage.assertSummaryCardReady(cardId);
+ });
+ mcpOverviewPage.elements.summaryCard('alerting-rules').should('contain.text', 'Alerting rules');
+ mcpOverviewPage.elements.summaryCard('firing-alerts').should('contain.text', 'Firing alerts');
+ mcpOverviewPage.elements.summaryCard('targets').should('contain.text', 'Targets');
+ mcpOverviewPage.elements.summaryCard('metrics').should('contain.text', 'Metrics');
+ mcpOverviewPage.elements.summaryCard('dashboards').should('contain.text', 'Perses Dashboards');
+
+ cy.log('1.5 Click Firing alerts count and verify Alerting page');
+ mcpOverviewPage.goTo();
+ mcpOverviewPage.clickSummaryCardCount('firing-alerts');
+ commonPages.titleShouldHaveText('Alerting');
+
+ cy.log('1.6 Click Alerting rules count and verify Alerting page');
+ mcpOverviewPage.goTo();
+ mcpOverviewPage.clickSummaryCardCount('alerting-rules');
+ commonPages.titleShouldHaveText('Alerting');
+
+ cy.log('1.7 Click Targets count and verify Metrics targets page');
+ mcpOverviewPage.goTo();
+ mcpOverviewPage.clickSummaryCardCount('targets');
+ commonPages.titleShouldHaveText('Metrics targets');
+
+ cy.log('1.8 Click Metrics count and verify Metrics page');
+ mcpOverviewPage.goTo();
+ mcpOverviewPage.clickSummaryCardCount('metrics');
+ commonPages.titleShouldHaveText('Metrics');
+
+ cy.log(
+ '1.9 Click Perses Dashboards count when available (may be error-only without COO/Perses)',
+ );
+ mcpOverviewPage.goTo();
+ mcpOverviewPage.clickSummaryCardCountIfAvailable('dashboards').then((navigated) => {
+ if (navigated) {
+ cy.url().should('include', '/monitoring/v2/dashboards');
+ }
+ });
+
+ cy.log('Verified: Observability services page load, alert dismiss, and card navigation');
+ });
+}
diff --git a/web/cypress/views/mcp-overview-page.ts b/web/cypress/views/mcp-overview-page.ts
new file mode 100644
index 000000000..d46134b68
--- /dev/null
+++ b/web/cypress/views/mcp-overview-page.ts
@@ -0,0 +1,127 @@
+import { DataTestIDs } from '@/shared/constants/data-test';
+
+import { commonPages } from './common';
+import { nav } from './nav';
+
+// Keep in sync with McoOverviewPage localStorage key
+const MCP_OVERVIEW_INFO_ALERT_DISMISSED = 'monitoring/mcp-overview/info-alert-dismissed';
+
+export type McpOverviewSummaryCardId =
+ | 'dashboards'
+ | 'alerting-rules'
+ | 'firing-alerts'
+ | 'targets'
+ | 'metrics';
+
+export const mcpOverviewPage = {
+ elements: {
+ infoAlert: () => cy.byTestID(DataTestIDs.McpOverviewPage.InfoAlert),
+ summarySection: () => cy.byTestID(DataTestIDs.McpOverviewPage.SummarySection),
+ summaryCard: (cardId: McpOverviewSummaryCardId) =>
+ cy.byTestID(`${DataTestIDs.McpOverviewPage.SummaryCard}-${cardId}`),
+ summaryCardCount: (cardId: McpOverviewSummaryCardId) =>
+ cy.byTestID(`${DataTestIDs.McpOverviewPage.SummaryCardCount}-${cardId}`),
+ summaryCardError: (cardId: McpOverviewSummaryCardId) =>
+ cy.byTestID(`${DataTestIDs.McpOverviewPage.SummaryCardError}-${cardId}`),
+ summaryCardLoading: (cardId: McpOverviewSummaryCardId) =>
+ cy.byTestID(`${DataTestIDs.McpOverviewPage.SummaryCardLoading}-${cardId}`),
+ },
+
+ goTo: () => {
+ cy.log('mcpOverviewPage.goTo');
+ nav.sidenav.clickNavLink(['Observe', 'Observability services']);
+ commonPages.titleShouldHaveText('Observability services');
+ },
+
+ clearInfoAlertDismissed: () => {
+ cy.log('mcpOverviewPage.clearInfoAlertDismissed');
+ cy.window().then((win) => {
+ win.localStorage.removeItem(MCP_OVERVIEW_INFO_ALERT_DISMISSED);
+ });
+ },
+
+ shouldBeLoaded: () => {
+ cy.log('mcpOverviewPage.shouldBeLoaded');
+ commonPages.titleShouldHaveText('Observability services');
+ mcpOverviewPage.elements.summarySection().should('be.visible');
+ cy.contains('Observability stack summary').should('be.visible');
+ },
+
+ dismissInfoAlert: () => {
+ cy.log('mcpOverviewPage.dismissInfoAlert');
+ mcpOverviewPage.elements.infoAlert().should('be.visible');
+ cy.byTestID(`${DataTestIDs.McpOverviewPage.InfoAlert}-close`).should('be.visible').click();
+ mcpOverviewPage.elements.infoAlert().should('not.exist');
+ },
+
+ waitForSummaryCardLoaded: (cardId: McpOverviewSummaryCardId) => {
+ cy.log(`mcpOverviewPage.waitForSummaryCardLoaded - ${cardId}`);
+ mcpOverviewPage.elements.summaryCard(cardId).should('be.visible');
+ cy.waitUntil(
+ () =>
+ Cypress.$(`[data-test="${DataTestIDs.McpOverviewPage.SummaryCardLoading}-${cardId}"]`)
+ .length === 0,
+ {
+ timeout: 60000,
+ interval: 500,
+ errorMsg: `Summary card "${cardId}" should finish loading within 60 seconds`,
+ },
+ );
+ },
+
+ /**
+ * Asserts the card finished loading. Accepts either a count button or an error state
+ * (Perses dashboards may error on CMO-only clusters without COO).
+ */
+ assertSummaryCardReady: (cardId: McpOverviewSummaryCardId) => {
+ cy.log(`mcpOverviewPage.assertSummaryCardReady - ${cardId}`);
+ mcpOverviewPage.waitForSummaryCardLoaded(cardId);
+ mcpOverviewPage.elements.summaryCard(cardId).within(() => {
+ cy.get('h3').should('be.visible');
+ cy.root().then(($card) => {
+ const hasCount = $card.find(
+ `[data-test="${DataTestIDs.McpOverviewPage.SummaryCardCount}-${cardId}"]`,
+ );
+ const hasError = $card.find(
+ `[data-test="${DataTestIDs.McpOverviewPage.SummaryCardError}-${cardId}"]`,
+ );
+ expect(
+ hasCount.length + hasError.length,
+ `card '${cardId}' should show count or error`,
+ ).to.be.greaterThan(0);
+ });
+ });
+ },
+
+ clickSummaryCardCount: (cardId: McpOverviewSummaryCardId) => {
+ cy.log(`mcpOverviewPage.clickSummaryCardCount - ${cardId}`);
+ mcpOverviewPage.waitForSummaryCardLoaded(cardId);
+ mcpOverviewPage.elements.summaryCardCount(cardId).should('be.visible').click();
+ },
+
+ /**
+ * Clicks the card count when available; skips navigation when the card is in error state.
+ * Returns whether navigation was attempted.
+ */
+ clickSummaryCardCountIfAvailable: (
+ cardId: McpOverviewSummaryCardId,
+ ): Cypress.Chainable => {
+ cy.log(`mcpOverviewPage.clickSummaryCardCountIfAvailable - ${cardId}`);
+ mcpOverviewPage.waitForSummaryCardLoaded(cardId);
+ return mcpOverviewPage.elements.summaryCard(cardId).then(($card) => {
+ if (
+ Cypress.$($card).find(
+ `[data-test="${DataTestIDs.McpOverviewPage.SummaryCardCount}-${cardId}"]`,
+ ).length > 0
+ ) {
+ return mcpOverviewPage.elements
+ .summaryCardCount(cardId)
+ .should('be.visible')
+ .click()
+ .then(() => true);
+ }
+ cy.log(`Skipping navigation for "${cardId}" - card is in error state`);
+ return cy.wrap(false);
+ });
+ },
+};
diff --git a/web/cypress/views/overview-page.ts b/web/cypress/views/overview-page.ts
index 5d5dc86d4..c2da7128d 100644
--- a/web/cypress/views/overview-page.ts
+++ b/web/cypress/views/overview-page.ts
@@ -1,18 +1,18 @@
import { commonPages } from './common';
-export const overviewPage = {
+export const mcpOverviewPage = {
shouldBeLoaded: () => {
- cy.log('overviewPage.shouldBeLoaded');
+ cy.log('mcpOverviewPage.shouldBeLoaded');
commonPages.titleShouldHaveText('Overview');
},
clickStatusViewAlerts: () => {
- cy.log('overviewPage.clickStatusViewAlerts');
+ cy.log('mcpOverviewPage.clickStatusViewAlerts');
cy.byTestID('status-card-view-alerts').should('be.visible').click();
},
clickStatusViewDetails: (position: number) => {
- cy.log('overviewPage.clickStatusViewAlerts');
+ cy.log('mcpOverviewPage.clickStatusViewAlerts');
cy.byClass('co-status-card__alert-item-more')
.contains('View details')
.eq(position)
@@ -21,7 +21,7 @@ export const overviewPage = {
},
clickClusterUtilizationViewCPU: () => {
- cy.log('overviewPage.clickClusterUtilizationViewCPU');
+ cy.log('mcpOverviewPage.clickClusterUtilizationViewCPU');
cy.byAriaLabel('View CPU metrics in query browser')
.scrollIntoView()
.should('be.visible')
diff --git a/web/locales/en/plugin__monitoring-plugin.json b/web/locales/en/plugin__monitoring-plugin.json
index 49b7a3101..8b9d1ee93 100644
--- a/web/locales/en/plugin__monitoring-plugin.json
+++ b/web/locales/en/plugin__monitoring-plugin.json
@@ -1,72 +1,10 @@
{
- "Recreate silence": "Recreate silence",
- "Edit silence": "Edit silence",
- "Expire silence": "Expire silence",
- "Starts": "Starts",
- "Ends": "Ends",
- "Expired": "Expired",
- "Name": "Name",
- "Firing alerts": "Firing alerts",
- "State": "State",
- "Creator": "Creator",
- "Alerts": "Alerts",
- "Silences": "Silences",
- "Alerting rules": "Alerting rules",
- "Alerting": "Alerting",
- "Severity": "Severity",
- "Namespace": "Namespace",
- "Source": "Source",
- "Cluster": "Cluster",
- "Silence alert": "Silence alert",
- "View AI Investigation": "View AI Investigation",
- "Loading investigations...": "Loading investigations...",
- "User": "User",
- "Platform": "Platform",
- "Export as CSV": "Export as CSV",
- "Description": "Description",
- "Active since": "Active since",
- "Value": "Value",
- "{{name}} details": "{{name}} details",
- "Alerting rule details": "Alerting rule details",
- "Summary": "Summary",
- "Message": "Message",
- "Runbook": "Runbook",
- "For": "For",
- "Expression": "Expression",
- "Labels": "Labels",
- "Active alerts": "Active alerts",
- "None found": "None found",
- "Filter by Name": "Filter by Name",
- "Alert State": "Alert State",
- "Filter by State": "Filter by State",
+ "Error loading silences from Alertmanager. Some of the alerts below may actually be silenced.": "Error loading silences from Alertmanager. Some of the alerts below may actually be silenced.",
"Firing": "Firing",
"Pending": "Pending",
"Silenced": "Silenced",
"Not Firing": "Not Firing",
- "Filter by Severity": "Filter by Severity",
- "Critical": "Critical",
- "Warning": "Warning",
- "Info": "Info",
- "None": "None",
- "Filter by Source": "Filter by Source",
- "Label": "Label",
- "Filter by Label": "Filter by Label",
- "Alert Rules Table": "Alert Rules Table",
- "No alerting rules found": "No alerting rules found",
- "Alert details": "Alert details",
- "Alerting rule": "Alerting rule",
- "Silenced by": "Silenced by",
- "Pending: ": "Pending: ",
- "The alert is active but is waiting for the duration that is specified in the alerting rule before it fires.": "The alert is active but is waiting for the duration that is specified in the alerting rule before it fires.",
- "Firing: ": "Firing: ",
- "The alert is firing because the alert condition is true and the optional `for` duration has passed. The alert will continue to fire as long as the condition remains true.": "The alert is firing because the alert condition is true and the optional `for` duration has passed. The alert will continue to fire as long as the condition remains true.",
- "Silenced: ": "Silenced: ",
- "The alert is now silenced for a defined time period. Silences temporarily mute alerts based on a set of label selectors that you define. Notifications will not be sent for alerts that match all the listed values or regular expressions.": "The alert is now silenced for a defined time period. Silences temporarily mute alerts based on a set of label selectors that you define. Notifications will not be sent for alerts that match all the listed values or regular expressions.",
- "Alert Name": "Alert Name",
- "Total": "Total",
- "Filter by Cluster": "Filter by Cluster",
- "No alerts found": "No alerts found",
- "Error loading silences from Alertmanager. Some of the alerts below may actually be silenced.": "Error loading silences from Alertmanager. Some of the alerts below may actually be silenced.",
+ "Ends": "Ends",
"Since": "Since",
"Inspect": "Inspect",
"The condition that triggered the alert could have a critical impact. The alert requires immediate attention when fired and is typically paged to an individual or to a critical response team.": "The condition that triggered the alert could have a critical impact. The alert requires immediate attention when fired and is typically paged to an individual or to a critical response team.",
@@ -78,9 +16,22 @@
"Platform-level alerts relate only to OpenShift namespaces. OpenShift namespaces provide core OpenShift functionality.": "Platform-level alerts relate only to OpenShift namespaces. OpenShift namespaces provide core OpenShift functionality.",
"User: ": "User: ",
"User workload alerts relate to user-defined namespaces. These alerts are user-created and are customizable. User workload monitoring can be enabled post-installation to provide observability into your own services.": "User workload alerts relate to user-defined namespaces. These alerts are user-created and are customizable. User workload monitoring can be enabled post-installation to provide observability into your own services.",
- "Create silence": "Create silence",
- "Overwriting current silence": "Overwriting current silence",
- "When changes are saved, the currently existing silence will be expired and a new silence with the new configuration will take its place.": "When changes are saved, the currently existing silence will be expired and a new silence with the new configuration will take its place.",
+ "Platform": "Platform",
+ "User": "User",
+ "Severity": "Severity",
+ "Critical": "Critical",
+ "Warning": "Warning",
+ "Info": "Info",
+ "None": "None",
+ "Recreate silence": "Recreate silence",
+ "Edit silence": "Edit silence",
+ "Expire silence": "Expire silence",
+ "Starts": "Starts",
+ "Expired": "Expired",
+ "Name": "Name",
+ "Firing alerts": "Firing alerts",
+ "State": "State",
+ "Creator": "Creator",
"Invalid date / time": "Invalid date / time",
"Datetime": "Datetime",
"Select the negative matcher option to update the label value to a not equals matcher.": "Select the negative matcher option to update the label value to a not equals matcher.",
@@ -110,6 +61,7 @@
"regular expression": "regular expression",
"Label name": "Label name",
"Label value": "Label value",
+ "Value": "Value",
"Select all that apply:": "Select all that apply:",
"RegEx": "RegEx",
"Negative matcher": "Negative matcher",
@@ -119,6 +71,65 @@
"Comment": "Comment",
"Silence": "Silence",
"Cancel": "Cancel",
+ "Active": "Active",
+ "Expire Silence": "Expire Silence",
+ "Are you sure you want to expire this silence?": "Are you sure you want to expire this silence?",
+ "An error occurred": "An error occurred",
+ "Source": "Source",
+ "Filter by Name": "Filter by Name",
+ "Alert State": "Alert State",
+ "Filter by State": "Filter by State",
+ "Filter by Severity": "Filter by Severity",
+ "Filter by Source": "Filter by Source",
+ "Label": "Label",
+ "Filter by Label": "Filter by Label",
+ "Alerting": "Alerting",
+ "Alert Rules Table": "Alert Rules Table",
+ "No alerting rules found": "No alerting rules found",
+ "Alerts": "Alerts",
+ "Silences": "Silences",
+ "Alerting rules": "Alerting rules",
+ "Description": "Description",
+ "Active since": "Active since",
+ "Silence alert": "Silence alert",
+ "{{name}} details": "{{name}} details",
+ "Alerting rule details": "Alerting rule details",
+ "Summary": "Summary",
+ "Message": "Message",
+ "Runbook": "Runbook",
+ "For": "For",
+ "Expression": "Expression",
+ "Labels": "Labels",
+ "Active alerts": "Active alerts",
+ "None found": "None found",
+ "Namespace": "Namespace",
+ "Cluster": "Cluster",
+ "Alert Name": "Alert Name",
+ "Total": "Total",
+ "Filter by Cluster": "Filter by Cluster",
+ "No alerts found": "No alerts found",
+ "View AI Investigation": "View AI Investigation",
+ "Loading investigations...": "Loading investigations...",
+ "Export as CSV": "Export as CSV",
+ "Alert details": "Alert details",
+ "Alerting rule": "Alerting rule",
+ "Silenced by": "Silenced by",
+ "Pending: ": "Pending: ",
+ "The alert is active but is waiting for the duration that is specified in the alerting rule before it fires.": "The alert is active but is waiting for the duration that is specified in the alerting rule before it fires.",
+ "Firing: ": "Firing: ",
+ "The alert is firing because the alert condition is true and the optional `for` duration has passed. The alert will continue to fire as long as the condition remains true.": "The alert is firing because the alert condition is true and the optional `for` duration has passed. The alert will continue to fire as long as the condition remains true.",
+ "Silenced: ": "Silenced: ",
+ "The alert is now silenced for a defined time period. Silences temporarily mute alerts based on a set of label selectors that you define. Notifications will not be sent for alerts that match all the listed values or regular expressions.": "The alert is now silenced for a defined time period. Silences temporarily mute alerts based on a set of label selectors that you define. Notifications will not be sent for alerts that match all the listed values or regular expressions.",
+ "Create silence": "Create silence",
+ "Overwriting current silence": "Overwriting current silence",
+ "When changes are saved, the currently existing silence will be expired and a new silence with the new configuration will take its place.": "When changes are saved, the currently existing silence will be expired and a new silence with the new configuration will take its place.",
+ "Silence State": "Silence State",
+ "Silences Table": "Silences Table",
+ "Error loading silences from Alertmanager. Alertmanager may be unavailable.": "Error loading silences from Alertmanager. Alertmanager may be unavailable.",
+ "No silences found": "No silences found",
+ "Error": "Error",
+ "Expire {{count}} silence_one": "Expire {{count}} silence",
+ "Expire {{count}} silence_other": "Expire {{count}} silences",
"Silence details": "Silence details",
"Actions": "Actions",
"Matchers": "Matchers",
@@ -129,41 +140,49 @@
"Created by": "Created by",
"No Alerts found": "No Alerts found",
"View alerting rule": "View alerting rule",
- "Silence State": "Silence State",
- "Active": "Active",
- "Silences Table": "Silences Table",
- "Error loading silences from Alertmanager. Alertmanager may be unavailable.": "Error loading silences from Alertmanager. Alertmanager may be unavailable.",
- "No silences found": "No silences found",
- "Error": "Error",
- "Expire {{count}} silence_one": "Expire {{count}} silence",
- "Expire {{count}} silence_other": "Expire {{count}} silences",
- "Expire Silence": "Expire Silence",
- "Are you sure you want to expire this silence?": "Are you sure you want to expire this silence?",
- "An error occurred": "An error occurred",
- "Restricted access": "Restricted access",
- "You don't have access to this section due to cluster policy": "You don't have access to this section due to cluster policy",
- "Error details": "Error details",
- "No {{label}} found": "No {{label}} found",
- "Not found": "Not found",
- "Try again": "Try again",
- "Error loading {{label}}": "Error loading {{label}}",
- "404: Not Found": "404: Not Found",
- "{{labels}} content is not available in the catalog at this time due to loading failures.": "{{labels}} content is not available in the catalog at this time due to loading failures.",
- "No datapoints found.": "No datapoints found.",
- "Filter {{label}}...": "Filter {{label}}...",
- "Create new option \"{{option}}\"": "Create new option \"{{option}}\"",
- "Filter options": "Filter options",
- "Clear input value": "Clear input value",
- "No results found": "No results found",
+ "Alerts Timeline": "Alerts Timeline",
+ "To view alerts, select an incident from the chart above or from the filters.": "To view alerts, select an incident from the chart above or from the filters.",
+ "Component": "Component",
+ "Start": "Start",
+ "End": "End",
+ "Resolved": "Resolved",
+ "Unknown": "Unknown",
+ "Incidents Timeline": "Incidents Timeline",
+ "ID": "ID",
+ "Component(s)": "Component(s)",
+ "Alert": "Alert",
+ "No incident selected.": "No incident selected.",
+ "The incident is critical.": "The incident is critical.",
+ "The incident might lead to critical.": "The incident might lead to critical.",
+ "Informative": "Informative",
+ "The incident is not critical.": "The incident is not critical.",
+ "The incident is currently firing.": "The incident is currently firing.",
+ "The incident is not currently firing.": "The incident is not currently firing.",
+ "Incidents": "Incidents",
+ "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.",
+ "Clear all filters": "Clear all filters",
+ "Filter type selection": "Filter type selection",
+ "Incident ID": "Incident ID",
+ "Severity filters": "Severity filters",
+ "State filters": "State filters",
+ "Incident ID filters": "Incident ID filters",
+ "Last 1 day": "Last 1 day",
+ "Last 3 days": "Last 3 days",
+ "Last 7 days": "Last 7 days",
+ "Last 15 days": "Last 15 days",
+ "Show graph": "Show graph",
+ "Hide graph": "Hide graph",
+ "component": "component",
+ "components": "components",
"Custom time range": "Custom time range",
"From": "From",
"To": "To",
"Save": "Save",
"Dashboards": "Dashboards",
"Metrics dashboards": "Metrics dashboards",
- "Error Loading Dashboards": "Error Loading Dashboards",
"Loading": "Loading",
"Error loading card": "Error loading card",
+ "Dashboard": "Dashboard",
"Error loading options": "Error loading options",
"Select a dashboard from the dropdown": "Select a dashboard from the dropdown",
"panel.styles attribute not found": "panel.styles attribute not found",
@@ -179,6 +198,47 @@
"Time range": "Time range",
"Refresh interval": "Refresh interval",
"Could not parse JSON data for dashboard \"{{dashboard}}\"": "Could not parse JSON data for dashboard \"{{dashboard}}\"",
+ "Error Loading Dashboards": "Error Loading Dashboards",
+ "Expression (press Shift+Enter for newlines)": "Expression (press Shift+Enter for newlines)",
+ "Access restricted.": "Access restricted.",
+ "Failed to load metrics list.": "Failed to load metrics list.",
+ "Clear query": "Clear query",
+ "Queries": "Queries",
+ "Select query": "Select query",
+ "Add query": "Add query",
+ "Collapse all query tables": "Collapse all query tables",
+ "Expand all query tables": "Expand all query tables",
+ "Delete all queries": "Delete all queries",
+ "Show series": "Show series",
+ "Hide series": "Hide series",
+ "Disable query": "Disable query",
+ "Enable query": "Enable query",
+ "Hide all series": "Hide all series",
+ "Show all series": "Show all series",
+ "Query must be enabled": "Query must be enabled",
+ "Delete query": "Delete query",
+ "Duplicate query": "Duplicate query",
+ "Error loading values": "Error loading values",
+ "No datapoints found.": "No datapoints found.",
+ "Unselect all": "Unselect all",
+ "Select all": "Select all",
+ "Error loading custom data source": "Error loading custom data source",
+ "An error occurred while loading the custom data source.": "An error occurred while loading the custom data source.",
+ "No query entered": "No query entered",
+ "Enter a query in the box below to explore metrics for this cluster.": "Enter a query in the box below to explore metrics for this cluster.",
+ "Insert example query": "Insert example query",
+ "Run queries": "Run queries",
+ "Bytes Binary (KiB, MiB)": "Bytes Binary (KiB, MiB)",
+ "Bytes Decimal (kb, MB)": "Bytes Decimal (kb, MB)",
+ "Bytes Binary Per Second (KiB/s, MiB/s)": "Bytes Binary Per Second (KiB/s, MiB/s)",
+ "Bytes Decimal Per Second (kB/s, MB/s)": "Bytes Decimal Per Second (kB/s, MB/s)",
+ "Packets Per Second": "Packets Per Second",
+ "Miliseconds": "Miliseconds",
+ "Seconds": "Seconds",
+ "Percentage": "Percentage",
+ "No Units": "No Units",
+ "Metrics": "Metrics",
+ "This dropdown only formats results.": "This dropdown only formats results.",
"Rename Dashboard": "Rename Dashboard",
"Dashboard name": "Dashboard name",
"Renaming...": "Renaming...",
@@ -198,8 +258,6 @@
"? 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!",
"Checking permissions...": "Checking permissions...",
"Create": "Create",
"Dashboard actions": "Dashboard actions",
@@ -229,38 +287,70 @@
"Perses dashboard detected.": "Perses dashboard detected.",
"2. Select project": "2. Select project",
"Importing...": "Importing...",
+ "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",
+ "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",
+ "All Projects": "All Projects",
+ "Project": "Project",
+ "useToast must be used within ToastProvider": "useToast must be used within ToastProvider",
+ "Project is required for fetching project dashboards": "Project is required for fetching project dashboards",
+ "Add To Dashboard": "Add To Dashboard",
+ "Add to dashboard": "Add to dashboard",
+ "Query: {{query}}": "Query: {{query}}",
"Rename dashboard": "Rename dashboard",
"Duplicate dashboard": "Duplicate dashboard",
"Delete dashboard": "Delete dashboard",
"You don't have permissions for dashboard actions": "You don't have permissions for 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 results found": "No results found",
"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 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",
- "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",
- "All Projects": "All Projects",
- "useToast must be used within ToastProvider": "useToast must be used within ToastProvider",
+ "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!",
+ "Perses Dashboards": "Perses Dashboards",
+ "Observability stack summary": "Observability stack summary",
+ "Inventory of configured observability surfaces. Select a number to open the related Observe view.": "Inventory of configured observability surfaces. Select a number to open the related Observe view.",
+ "Targets": "Targets",
+ "Observability services": "Observability services",
+ "Manage and monitor your metrics, logs, and traces from a single, unified hub.": "Manage and monitor your metrics, logs, and traces from a single, unified hub.",
+ "Cluster-wide observability scope": "Cluster-wide observability scope",
+ "Close observability capabilities information message": "Close observability capabilities information message",
+ "This hub reflects observability capabilities for the current cluster after Cluster Observability Operator installation. Status labels indicate enablement and configuration readiness—not live telemetry severity.": "This hub reflects observability capabilities for the current cluster after Cluster Observability Operator installation. Status labels indicate enablement and configuration readiness—not live telemetry severity.",
+ "Up": "Up",
+ "Down": "Down",
+ "Target details": "Target details",
+ "Error loading service monitor data": "Error loading service monitor data",
+ "Error loading pod monitor data": "Error loading pod monitor data",
+ "Endpoint": "Endpoint",
+ "Last scrape": "Last scrape",
+ "Scrape failed": "Scrape failed",
+ "Status": "Status",
+ "Monitor": "Monitor",
+ "Text": "Text",
+ "Last Scrape": "Last Scrape",
+ "Scrape Duration": "Scrape Duration",
+ "Search by endpoint or namespace...": "Search by endpoint or namespace...",
+ "Filter by Status": "Filter by Status",
+ "Metrics targets": "Metrics targets",
+ "Error loading latest targets data": "Error loading latest targets data",
+ "Targets Table": "Targets Table",
+ "No metrics targets found": "No metrics targets found",
"Refresh off": "Refresh off",
"{{count}} second_one": "{{count}} second",
"{{count}} second_other": "{{count}} seconds",
@@ -270,82 +360,7 @@
"{{count}} hour_other": "{{count}} hours",
"{{count}} day_one": "{{count}} day",
"{{count}} day_other": "{{count}} days",
- "Alerts Timeline": "Alerts Timeline",
- "To view alerts, select an incident from the chart above or from the filters.": "To view alerts, select an incident from the chart above or from the filters.",
- "Component": "Component",
- "Start": "Start",
- "End": "End",
- "Resolved": "Resolved",
- "Unknown": "Unknown",
- "Incidents Timeline": "Incidents Timeline",
- "ID": "ID",
- "Component(s)": "Component(s)",
- "Alert": "Alert",
- "Incidents": "Incidents",
- "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",
- "State filters": "State filters",
- "Incident ID filters": "Incident ID filters",
- "Last 1 day": "Last 1 day",
- "Last 3 days": "Last 3 days",
- "Last 7 days": "Last 7 days",
- "Last 15 days": "Last 15 days",
- "Show graph": "Show graph",
- "Hide graph": "Hide graph",
- "No incident selected.": "No incident selected.",
- "The incident is critical.": "The incident is critical.",
- "The incident might lead to critical.": "The incident might lead to critical.",
- "Informative": "Informative",
- "The incident is not critical.": "The incident is not critical.",
- "The incident is currently firing.": "The incident is currently firing.",
- "The incident is not currently firing.": "The incident is not currently firing.",
- "component": "component",
- "components": "components",
"No labels": "No labels",
- "Expression (press Shift+Enter for newlines)": "Expression (press Shift+Enter for newlines)",
- "Access restricted.": "Access restricted.",
- "Failed to load metrics list.": "Failed to load metrics list.",
- "Clear query": "Clear query",
- "Queries": "Queries",
- "Select query": "Select query",
- "Add query": "Add query",
- "Collapse all query tables": "Collapse all query tables",
- "Expand all query tables": "Expand all query tables",
- "Delete all queries": "Delete all queries",
- "Show series": "Show series",
- "Hide series": "Hide series",
- "Disable query": "Disable query",
- "Enable query": "Enable query",
- "Hide all series": "Hide all series",
- "Show all series": "Show all series",
- "Query must be enabled": "Query must be enabled",
- "Delete query": "Delete query",
- "Duplicate query": "Duplicate query",
- "Error loading values": "Error loading values",
- "Unselect all": "Unselect all",
- "Select all": "Select all",
- "Error loading custom data source": "Error loading custom data source",
- "An error occurred while loading the custom data source.": "An error occurred while loading the custom data source.",
- "No query entered": "No query entered",
- "Enter a query in the box below to explore metrics for this cluster.": "Enter a query in the box below to explore metrics for this cluster.",
- "Insert example query": "Insert example query",
- "Run queries": "Run queries",
- "Bytes Binary (KiB, MiB)": "Bytes Binary (KiB, MiB)",
- "Bytes Decimal (kb, MB)": "Bytes Decimal (kb, MB)",
- "Bytes Binary Per Second (KiB/s, MiB/s)": "Bytes Binary Per Second (KiB/s, MiB/s)",
- "Bytes Decimal Per Second (kB/s, MB/s)": "Bytes Decimal Per Second (kB/s, MB/s)",
- "Packets Per Second": "Packets Per Second",
- "Miliseconds": "Miliseconds",
- "Seconds": "Seconds",
- "Percentage": "Percentage",
- "No Units": "No Units",
- "Metrics": "Metrics",
- "This dropdown only formats results.": "This dropdown only formats results.",
- "Add To Dashboard": "Add To Dashboard",
- "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.",
@@ -367,24 +382,17 @@
"Current page": "Current page",
"Pagination": "Pagination",
"of": "of",
- "Up": "Up",
- "Down": "Down",
- "Target details": "Target details",
- "Targets": "Targets",
- "Error loading service monitor data": "Error loading service monitor data",
- "Error loading pod monitor data": "Error loading pod monitor data",
- "Endpoint": "Endpoint",
- "Last scrape": "Last scrape",
- "Scrape failed": "Scrape failed",
- "Status": "Status",
- "Monitor": "Monitor",
- "Text": "Text",
- "Last Scrape": "Last Scrape",
- "Scrape Duration": "Scrape Duration",
- "Search by endpoint or namespace...": "Search by endpoint or namespace...",
- "Filter by Status": "Filter by Status",
- "Metrics targets": "Metrics targets",
- "Error loading latest targets data": "Error loading latest targets data",
- "Targets Table": "Targets Table",
- "No metrics targets found": "No metrics targets found"
-}
+ "Restricted access": "Restricted access",
+ "You don't have access to this section due to cluster policy": "You don't have access to this section due to cluster policy",
+ "Error details": "Error details",
+ "No {{label}} found": "No {{label}} found",
+ "Not found": "Not found",
+ "Try again": "Try again",
+ "Error loading {{label}}": "Error loading {{label}}",
+ "404: Not Found": "404: Not Found",
+ "{{labels}} content is not available in the catalog at this time due to loading failures.": "{{labels}} content is not available in the catalog at this time due to loading failures.",
+ "Filter {{label}}...": "Filter {{label}}...",
+ "Create new option \"{{option}}\"": "Create new option \"{{option}}\"",
+ "Filter options": "Filter options",
+ "Clear input value": "Clear input value"
+}
\ No newline at end of file
diff --git a/web/package.json b/web/package.json
index d477b648a..4e7f62e88 100644
--- a/web/package.json
+++ b/web/package.json
@@ -183,6 +183,7 @@
"SilenceCreatePage": "./features/alerts/pages/SilenceCreatePage",
"SilenceEditPage": "./features/alerts/pages/SilenceEditPage",
"MetricsPage": "./features/metrics/pages/MetricsPage",
+ "McpOverviewPage": "./features/mcp-overview/pages/McpOverviewPage",
"AlertsPage": "./features/alerts/pages/alerts-page/AlertsPage",
"AlertingPage": "./features/alerts/pages/AlertingPage",
"AlertsDetailsPage": "./features/alerts/pages/AlertsDetailsPage",
diff --git a/web/src/features/alerts/components/AlertUtils.tsx b/web/src/features/alerts/components/AlertUtils.tsx
index d4a84b0fb..f96e12893 100644
--- a/web/src/features/alerts/components/AlertUtils.tsx
+++ b/web/src/features/alerts/components/AlertUtils.tsx
@@ -73,17 +73,6 @@ export const getAdditionalSources = (
return [];
};
-export const alertingRuleSource = (rule: Rule): AlertSource | string => {
- if (rule.sourceId === undefined || rule.sourceId === 'prometheus') {
- return rule.labels?.prometheus === 'openshift-monitoring/k8s'
- ? AlertSource.Platform
- : AlertSource.User;
- }
-
- return rule.sourceId;
-};
-
-export const alertSource = (alert: Alert): AlertSource | string => alertingRuleSource(alert.rule);
export const alertCluster = (alert: Alert): string => alert.labels?.cluster ?? '';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx b/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx
index 424023087..706618f98 100644
--- a/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx
+++ b/web/src/features/alerts/pages/AlertRulesDetailsPage.tsx
@@ -42,7 +42,6 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router';
import {
- alertingRuleSource,
AlertState,
getSourceKey,
Graph,
@@ -68,6 +67,7 @@ import {
getQueryBrowserUrl,
usePerspective,
} from '@/shared/hooks/usePerspective';
+import { alertingRuleSource } from '@/shared/utils/alert-utils';
import { alertDescription, RuleResource } from '@/shared/utils/utils';
// Renders Prometheus template text and highlights any {{ ... }} tags that it contains
diff --git a/web/src/features/alerts/pages/AlertsDetailsPage.tsx b/web/src/features/alerts/pages/AlertsDetailsPage.tsx
index 45ab9687d..cfbe316b7 100644
--- a/web/src/features/alerts/pages/AlertsDetailsPage.tsx
+++ b/web/src/features/alerts/pages/AlertsDetailsPage.tsx
@@ -46,7 +46,6 @@ import { useSelector } from 'react-redux';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router';
import {
- alertSource,
AlertState,
AlertStateDescription,
AlertStateIcon,
@@ -87,6 +86,7 @@ import {
usePerspective,
} from '@/shared/hooks/usePerspective';
import { MonitoringState } from '@/shared/store/store';
+import { alertSource } from '@/shared/utils/alert-utils';
import { AlertResource, alertState, RuleResource } from '@/shared/utils/utils';
const AlertsDetailsPage_: FC = () => {
diff --git a/web/src/features/alerts/pages/alert-rules-page/AlertRulesPage.tsx b/web/src/features/alerts/pages/alert-rules-page/AlertRulesPage.tsx
index 8d87403df..c1aa3396d 100644
--- a/web/src/features/alerts/pages/alert-rules-page/AlertRulesPage.tsx
+++ b/web/src/features/alerts/pages/alert-rules-page/AlertRulesPage.tsx
@@ -18,7 +18,6 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import {
- alertingRuleSource,
AlertStateIcon,
getAlertStateKey,
SilencesNotLoadedWarning,
@@ -46,6 +45,7 @@ import { useAlerts } from '@/shared/hooks/useAlerts';
import { useMonitoringNamespace } from '@/shared/hooks/useMonitoringNamespace';
import { getRuleUrl, usePerspective } from '@/shared/hooks/usePerspective';
import { AlertSource } from '@/shared/types/types';
+import { alertingRuleSource } from '@/shared/utils/alert-utils';
import { alertingRuleStateSort, RuleResource, severitySort } from '@/shared/utils/utils';
export const enum AlertRulesFilterOptions {
diff --git a/web/src/features/alerts/pages/alert-rules-page/filter-rules.ts b/web/src/features/alerts/pages/alert-rules-page/filter-rules.ts
index 866660ccd..f91d42a31 100644
--- a/web/src/features/alerts/pages/alert-rules-page/filter-rules.ts
+++ b/web/src/features/alerts/pages/alert-rules-page/filter-rules.ts
@@ -1,12 +1,12 @@
import { AlertStates, Rule } from '@openshift-console/dynamic-plugin-sdk';
import { isEmpty, some } from 'lodash-es';
-import { alertingRuleSource } from '@/features/alerts/components/AlertUtils';
import {
AlertRulesFilterOptions,
AlertRulesFilters,
} from '@/features/alerts/pages/alert-rules-page/AlertRulesPage';
import { AlertSource } from '@/shared/types/types';
+import { alertingRuleSource } from '@/shared/utils/alert-utils';
import { fuzzyCaseInsensitive } from '@/shared/utils/utils';
export const filterRules = (rules: Rule[], selectedFilters: AlertRulesFilters) => {
diff --git a/web/src/features/alerts/pages/alerts-page/AggregateAlertTableRow.tsx b/web/src/features/alerts/pages/alerts-page/AggregateAlertTableRow.tsx
index a81b0df97..ee98c2af0 100644
--- a/web/src/features/alerts/pages/alerts-page/AggregateAlertTableRow.tsx
+++ b/web/src/features/alerts/pages/alerts-page/AggregateAlertTableRow.tsx
@@ -7,13 +7,12 @@ import { Link } from 'react-router';
import { AlertState } from '@/features/alerts/components/AlertUtils';
import { AggregatedAlert } from '@/features/alerts/pages/alerts-page/alerts-aggregates';
-import { AggregatedAlertFilters } from '@/features/alerts/pages/alerts-page/AlertsPage';
import AlertTableRow from '@/features/alerts/pages/alerts-page/AlertTableRow';
-import { filterAlerts } from '@/features/alerts/pages/alerts-page/filter-alerts';
import { SeverityBadge } from '@/shared/components/SeverityBadge';
import { DataTestIDs } from '@/shared/constants/data-test';
import { useMonitoringNamespace } from '@/shared/hooks/useMonitoringNamespace';
import { getRuleUrl, usePerspective } from '@/shared/hooks/usePerspective';
+import { AggregatedAlertFilters, filterAlerts } from '@/shared/utils/alert-utils';
import { RuleResource } from '@/shared/utils/utils';
type AggregateAlertTableRowProps = {
diff --git a/web/src/features/alerts/pages/alerts-page/AlertTableRow.tsx b/web/src/features/alerts/pages/alerts-page/AlertTableRow.tsx
index ed8299e8c..4236939da 100644
--- a/web/src/features/alerts/pages/alerts-page/AlertTableRow.tsx
+++ b/web/src/features/alerts/pages/alerts-page/AlertTableRow.tsx
@@ -13,7 +13,6 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router';
import {
- alertSource,
AlertState,
AlertStateDescription,
isActionWithCallback,
@@ -28,6 +27,7 @@ import { DataTestIDs } from '@/shared/constants/data-test';
import { useMonitoringNamespace } from '@/shared/hooks/useMonitoringNamespace';
import { getAlertUrl, getNewSilenceAlertUrl, usePerspective } from '@/shared/hooks/usePerspective';
import { AlertSource } from '@/shared/types/types';
+import { alertSource } from '@/shared/utils/alert-utils';
import { AlertResource, alertState } from '@/shared/utils/utils';
const getAgenticRunUrl = (namespace: string, name: string): string => {
diff --git a/web/src/features/alerts/pages/alerts-page/AlertsPage.tsx b/web/src/features/alerts/pages/alerts-page/AlertsPage.tsx
index 8b9a2b6df..ada2940c3 100644
--- a/web/src/features/alerts/pages/alerts-page/AlertsPage.tsx
+++ b/web/src/features/alerts/pages/alerts-page/AlertsPage.tsx
@@ -16,7 +16,6 @@ import {
getAggregateAlertsLists,
} from '@/features/alerts/pages/alerts-page/alerts-aggregates';
import DownloadCSVButton from '@/features/alerts/pages/alerts-page/DownloadCSVButton';
-import { filterAlerts } from '@/features/alerts/pages/alerts-page/filter-alerts';
import { useTableColumns } from '@/shared/components/table/hooks/useTableColumns';
import { rowFilter, useTableFilters } from '@/shared/components/table/hooks/useTableFilters';
import { useTablePagination } from '@/shared/components/table/hooks/useTablePagination';
@@ -39,26 +38,13 @@ import { useDeepMemo } from '@/shared/hooks/useDeepMemo';
import { useMonitoringNamespace } from '@/shared/hooks/useMonitoringNamespace';
import { usePerspective } from '@/shared/hooks/usePerspective';
import { AlertSource } from '@/shared/types/types';
+import {
+ type AggregatedAlertFilters,
+ AlertFilterOptions,
+ filterAlerts,
+} from '@/shared/utils/alert-utils';
import { ALL_NAMESPACES_KEY, severitySort } from '@/shared/utils/utils';
-export const enum AlertFilterOptions {
- NAME = 'name',
- STATE = 'alert-state',
- SEVERITY = 'alert-severity',
- LABEL = 'label',
- SOURCE = 'alert-source',
- CLUSTER = 'alert-cluster',
-}
-
-export interface AggregatedAlertFilters {
- [AlertFilterOptions.NAME]: string;
- [AlertFilterOptions.STATE]: string[];
- [AlertFilterOptions.SEVERITY]: string[];
- [AlertFilterOptions.LABEL]: string;
- [AlertFilterOptions.SOURCE]?: AlertSource[];
- [AlertFilterOptions.CLUSTER]?: string[];
-}
-
const AlertsPage_: FC = () => {
const { t } = useTranslation(process.env.I18N_NAMESPACE);
const { namespace } = useMonitoringNamespace();
diff --git a/web/src/features/mcp-overview/OWNERS b/web/src/features/mcp-overview/OWNERS
new file mode 100644
index 000000000..9a685050f
--- /dev/null
+++ b/web/src/features/mcp-overview/OWNERS
@@ -0,0 +1,4 @@
+reviewers:
+ - observability-ui-devs
+approvers:
+ - observability-ui
diff --git a/web/src/features/mcp-overview/components/summary/AlertSummaryCards.tsx b/web/src/features/mcp-overview/components/summary/AlertSummaryCards.tsx
new file mode 100644
index 000000000..c8c79dcc7
--- /dev/null
+++ b/web/src/features/mcp-overview/components/summary/AlertSummaryCards.tsx
@@ -0,0 +1,131 @@
+import { type Alert, AlertStates, type Rule } from '@openshift-console/dynamic-plugin-sdk';
+import { FlexItem } from '@patternfly/react-core';
+import type { FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import SummaryCard from '@/features/mcp-overview/components/summary/SummaryCard';
+import { useAlerts } from '@/shared/hooks/useAlerts';
+import { getAlertRulesUrl, getAlertsUrl, usePerspective } from '@/shared/hooks/usePerspective';
+import type { Perspective } from '@/shared/store/actions';
+import type { AlertSource } from '@/shared/types/types';
+import { AlertFilterOptions, alertingRuleSource, filterAlerts } from '@/shared/utils/alert-utils';
+import { ALL_NAMESPACES_KEY } from '@/shared/utils/utils';
+
+type RulesAlertLoading = {
+ loaded?: boolean;
+ loadError?: unknown;
+};
+
+export const getAlertsError = (rulesAlertLoading?: RulesAlertLoading): string | undefined => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const loadError = (rulesAlertLoading?.loadError as any)?.message ?? rulesAlertLoading?.loadError;
+ return loadError == null ? undefined : String(loadError);
+};
+
+export const getFiringAlerts = ({
+ alerts,
+ alertsError,
+ loaded,
+ defaultAlertTenant,
+ perspective,
+}: {
+ alerts?: Alert[];
+ alertsError?: string;
+ loaded?: boolean;
+ defaultAlertTenant: AlertSource[];
+ perspective: Perspective;
+}): Alert[] => {
+ if (alertsError || !loaded) {
+ return [];
+ }
+
+ const filters = {
+ [AlertFilterOptions.NAME]: '',
+ [AlertFilterOptions.STATE]: [AlertStates.Firing],
+ [AlertFilterOptions.SEVERITY]: [],
+ [AlertFilterOptions.SOURCE]: defaultAlertTenant,
+ [AlertFilterOptions.LABEL]: '',
+ };
+
+ return filterAlerts(alerts, filters, ALL_NAMESPACES_KEY, perspective);
+};
+
+export const getAlertSummaryState = ({
+ alerts,
+ rules,
+ rulesAlertLoading,
+ defaultAlertTenant,
+ perspective,
+}: {
+ alerts?: Alert[];
+ rules?: Rule[];
+ rulesAlertLoading?: RulesAlertLoading;
+ defaultAlertTenant: AlertSource[];
+ perspective: Perspective;
+}) => {
+ const alertsError = getAlertsError(rulesAlertLoading);
+ const loaded = rulesAlertLoading?.loaded;
+ const firingAlerts = getFiringAlerts({
+ alerts,
+ alertsError,
+ loaded,
+ defaultAlertTenant,
+ perspective,
+ });
+
+ const rulesCount =
+ rules?.filter(
+ (rule) =>
+ defaultAlertTenant.length === 0 ||
+ defaultAlertTenant.includes(alertingRuleSource(rule) as AlertSource),
+ ).length ?? 0;
+
+ return {
+ loading: !loaded,
+ error: alertsError,
+ rulesCount,
+ firingAlertsCount: firingAlerts.length,
+ };
+};
+
+const AlertSummaryCards: FC = () => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+ const { defaultAlertTenant, perspective } = usePerspective();
+
+ const { alerts, rules, rulesAlertLoading } = useAlerts({ dontUseTenancy: true });
+
+ const { loading, error, rulesCount, firingAlertsCount } = getAlertSummaryState({
+ alerts,
+ rules,
+ rulesAlertLoading,
+ defaultAlertTenant,
+ perspective,
+ });
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
+
+export default AlertSummaryCards;
diff --git a/web/src/features/mcp-overview/components/summary/DashboardsSummaryCard.tsx b/web/src/features/mcp-overview/components/summary/DashboardsSummaryCard.tsx
new file mode 100644
index 000000000..5b2ba4968
--- /dev/null
+++ b/web/src/features/mcp-overview/components/summary/DashboardsSummaryCard.tsx
@@ -0,0 +1,57 @@
+import { useQuery } from '@tanstack/react-query';
+import { type FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import SummaryCard from '@/features/mcp-overview/components/summary/SummaryCard';
+import { getDashboardsListUrl, usePerspective } from '@/shared/hooks/usePerspective';
+import { fetchPersesDashboardsMetadata } from '@/shared/utils/perses-client';
+
+const REFRESH_INTERVAL = 30 * 1000;
+
+export const getDashboardsSummaryState = ({
+ isLoading,
+ error,
+ data,
+}: {
+ isLoading: boolean;
+ error: unknown;
+ data?: unknown[] | null;
+}) => ({
+ loading: isLoading,
+ count: error ? 0 : (data?.length ?? 0),
+ error: error instanceof Error ? error.message : error ? String(error) : undefined,
+});
+
+const DashboardsSummaryCard: FC = () => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+ const { perspective } = usePerspective();
+
+ const { isLoading, error, data } = useQuery({
+ queryKey: ['dashboards'],
+ queryFn: fetchPersesDashboardsMetadata,
+ refetchInterval: REFRESH_INTERVAL,
+ });
+
+ const {
+ loading,
+ count,
+ error: errorMessage,
+ } = getDashboardsSummaryState({
+ isLoading,
+ error,
+ data,
+ });
+
+ return (
+
+ );
+};
+
+export default DashboardsSummaryCard;
diff --git a/web/src/features/mcp-overview/components/summary/MetricsSummaryCard.tsx b/web/src/features/mcp-overview/components/summary/MetricsSummaryCard.tsx
new file mode 100644
index 000000000..ce8f6acf7
--- /dev/null
+++ b/web/src/features/mcp-overview/components/summary/MetricsSummaryCard.tsx
@@ -0,0 +1,70 @@
+import { PrometheusEndpoint } from '@openshift-console/dynamic-plugin-sdk';
+import { type FC, useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import SummaryCard from '@/features/mcp-overview/components/summary/SummaryCard';
+import { usePoll } from '@/shared/console/utils/poll-hook';
+import { useSafeFetch } from '@/shared/console/utils/safe-fetch-hook';
+import { useMonitoringNamespace } from '@/shared/hooks/useMonitoringNamespace';
+import { getMutlipleQueryBrowserUrl, usePerspective } from '@/shared/hooks/usePerspective';
+import { PROMETHEUS_BASE_PATH } from '@/shared/utils/utils';
+
+const POLL_INTERVAL = 15 * 1000;
+
+const METRICS_URL = `${PROMETHEUS_BASE_PATH}/${PrometheusEndpoint.LABEL}/__name__/values`;
+
+type LabelValuesResponse = {
+ data?: string[];
+};
+
+type MetricsResults = {
+ metricsCount: number;
+ metricsLoading: boolean;
+ metricsError?: string;
+};
+
+const MetricsSummaryCard: FC = () => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+ const { perspective } = usePerspective();
+ const { namespace } = useMonitoringNamespace();
+ const [metricsResults, setMetricsResults] = useState({
+ metricsCount: 0,
+ metricsLoading: true,
+ });
+
+ const safeFetch = useSafeFetch();
+
+ const tick = useCallback(() => {
+ safeFetch(METRICS_URL)
+ .then((response) => {
+ setMetricsResults({
+ metricsCount: response?.data?.length ?? 0,
+ metricsLoading: false,
+ });
+ })
+ .catch((err) => {
+ if (err.name !== 'AbortError') {
+ setMetricsResults({
+ metricsCount: 0,
+ metricsLoading: false,
+ metricsError: err?.json?.error ?? err.message,
+ });
+ }
+ });
+ }, [safeFetch]);
+
+ usePoll(tick, POLL_INTERVAL);
+
+ return (
+
+ );
+};
+
+export default MetricsSummaryCard;
diff --git a/web/src/features/mcp-overview/components/summary/ObservabilityStackSummary.tsx b/web/src/features/mcp-overview/components/summary/ObservabilityStackSummary.tsx
new file mode 100644
index 000000000..ff5ae97f5
--- /dev/null
+++ b/web/src/features/mcp-overview/components/summary/ObservabilityStackSummary.tsx
@@ -0,0 +1,46 @@
+import { Content, ContentVariants, Flex, FlexItem } from '@patternfly/react-core';
+import { type FC } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import AlertSummaryCards from '@/features/mcp-overview/components/summary/AlertSummaryCards';
+import DashboardsSummaryCard from '@/features/mcp-overview/components/summary/DashboardsSummaryCard';
+import MetricsSummaryCard from '@/features/mcp-overview/components/summary/MetricsSummaryCard';
+import TargetsSummaryCard from '@/features/mcp-overview/components/summary/TargetsSummaryCard';
+import { DataTestIDs } from '@/shared/constants/data-test';
+
+const ObservabilityStackSummary: FC = () => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+
+ return (
+
+
+ {t('Observability stack summary')}
+
+ {t(
+ 'Inventory of configured observability surfaces. Select a number to open the related Observe view.',
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ObservabilityStackSummary;
diff --git a/web/src/features/mcp-overview/components/summary/SummaryCard.tsx b/web/src/features/mcp-overview/components/summary/SummaryCard.tsx
new file mode 100644
index 000000000..16a679d99
--- /dev/null
+++ b/web/src/features/mcp-overview/components/summary/SummaryCard.tsx
@@ -0,0 +1,64 @@
+import { RedExclamationCircleIcon } from '@openshift-console/dynamic-plugin-sdk';
+import { Button, Card, CardBody, Content, Icon, Tooltip } from '@patternfly/react-core';
+import { type FC } from 'react';
+import { useNavigate } from 'react-router';
+
+import { Loading } from '@/shared/console/console-shared/src/components/loading/Loading';
+import { DataTestIDs } from '@/shared/constants/data-test';
+
+export type SummaryCardProps = {
+ count: number;
+ title: string;
+ url: string;
+ cardId: string;
+ loading?: boolean;
+ error?: string;
+};
+
+const SummaryCard: FC = ({ count, title, url, cardId, loading, error }) => {
+ const navigate = useNavigate();
+
+ return (
+
+
+ {title}
+ {loading ? (
+
+
+
+ ) : error ? (
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default SummaryCard;
diff --git a/web/src/features/mcp-overview/components/summary/TargetsSummaryCard.tsx b/web/src/features/mcp-overview/components/summary/TargetsSummaryCard.tsx
new file mode 100644
index 000000000..06c932af0
--- /dev/null
+++ b/web/src/features/mcp-overview/components/summary/TargetsSummaryCard.tsx
@@ -0,0 +1,68 @@
+import { PrometheusEndpoint } from '@openshift-console/dynamic-plugin-sdk';
+import { type FC, useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import SummaryCard from '@/features/mcp-overview/components/summary/SummaryCard';
+import { usePoll } from '@/shared/console/utils/poll-hook';
+import { useSafeFetch } from '@/shared/console/utils/safe-fetch-hook';
+import { PROMETHEUS_BASE_PATH } from '@/shared/utils/utils';
+
+const POLL_INTERVAL = 15 * 1000;
+
+const TARGETS_URL = `${PROMETHEUS_BASE_PATH}/${PrometheusEndpoint.TARGETS}?state=active`;
+
+type PrometheusTargetsResponse = {
+ data?: {
+ activeTargets?: unknown[];
+ };
+};
+
+type TargetResults = {
+ targetsCount: number;
+ targetsLoading: boolean;
+ targetsError?: string;
+};
+
+const TargetsSummaryCard: FC = () => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+ const [targetResults, setTargetResults] = useState({
+ targetsCount: 0,
+ targetsLoading: true,
+ });
+
+ const safeFetch = useSafeFetch();
+
+ const tick = useCallback(() => {
+ safeFetch(TARGETS_URL)
+ .then((response) => {
+ setTargetResults({
+ targetsCount: response?.data?.activeTargets?.length ?? 0,
+ targetsLoading: false,
+ });
+ })
+ .catch((err) => {
+ if (err.name !== 'AbortError') {
+ setTargetResults({
+ targetsCount: 0,
+ targetsLoading: false,
+ targetsError: err.json?.error ?? err.message,
+ });
+ }
+ });
+ }, [safeFetch]);
+
+ usePoll(tick, POLL_INTERVAL);
+
+ return (
+
+ );
+};
+
+export default TargetsSummaryCard;
diff --git a/web/src/features/mcp-overview/components/summary/alert-summary-cards.spec.ts b/web/src/features/mcp-overview/components/summary/alert-summary-cards.spec.ts
new file mode 100644
index 000000000..21c76deb8
--- /dev/null
+++ b/web/src/features/mcp-overview/components/summary/alert-summary-cards.spec.ts
@@ -0,0 +1,242 @@
+jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({
+ ...jest.requireActual('@openshift-console/dynamic-plugin-sdk/lib/api/common-types'),
+}));
+
+jest.mock('@patternfly/react-core', () => ({
+ FlexItem: 'FlexItem',
+}));
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('@/features/mcp-overview/components/summary/SummaryCard', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+
+jest.mock('@/shared/hooks/useAlerts', () => ({
+ useAlerts: jest.fn(() => ({
+ alerts: [],
+ rules: [],
+ rulesAlertLoading: { loaded: false },
+ })),
+}));
+
+jest.mock('@/shared/hooks/usePerspective', () => ({
+ usePerspective: jest.fn(() => ({
+ perspective: 'admin',
+ defaultAlertTenant: ['platform'],
+ })),
+ getAlertRulesUrl: jest.fn(() => '/monitoring/alertrules'),
+ getAlertsUrl: jest.fn(() => '/monitoring/alerts'),
+}));
+
+import { AlertStates } from '@openshift-console/dynamic-plugin-sdk';
+import type { Alert, Rule } from '@openshift-console/dynamic-plugin-sdk';
+
+import {
+ getAlertsError,
+ getAlertSummaryState,
+ getFiringAlerts,
+} from '@/features/mcp-overview/components/summary/AlertSummaryCards';
+import { AlertSource } from '@/shared/types/types';
+
+/** Minimal rule shape needed for tenant/source classification in fixtures. */
+type AlertRuleFixture = Pick;
+
+type AlertFixture = Omit, 'rule'> & {
+ rule?: AlertRuleFixture;
+};
+
+const makeRule = (overrides: Partial = {}): Rule =>
+ ({
+ alerts: [],
+ labels: {},
+ ...overrides,
+ }) as unknown as Rule;
+
+const platformRule = (overrides: Partial = {}): Rule =>
+ makeRule({
+ labels: { prometheus: 'openshift-monitoring/k8s' },
+ ...overrides,
+ });
+
+const userRule = (overrides: Partial = {}): Rule =>
+ makeRule({
+ labels: {},
+ ...overrides,
+ });
+
+const makeAlert = (overrides: AlertFixture = {}): Alert =>
+ ({
+ labels: {},
+ state: AlertStates.Firing,
+ rule: { labels: {} } satisfies AlertRuleFixture,
+ ...overrides,
+ }) as unknown as Alert;
+
+const platformFiring = makeAlert({
+ labels: {
+ alertname: 'HighCPU',
+ prometheus: 'openshift-monitoring/k8s',
+ },
+ state: AlertStates.Firing,
+ rule: { labels: { prometheus: 'openshift-monitoring/k8s' } },
+});
+
+const userFiring = makeAlert({
+ labels: { alertname: 'UserAlert' },
+ state: AlertStates.Firing,
+ rule: { labels: {} },
+});
+
+const pendingAlert = makeAlert({
+ labels: {
+ alertname: 'PendingAlert',
+ prometheus: 'openshift-monitoring/k8s',
+ },
+ state: AlertStates.Pending,
+ rule: { labels: { prometheus: 'openshift-monitoring/k8s' } },
+});
+
+describe('getAlertsError', () => {
+ it('should return undefined when there is no load error', () => {
+ expect(getAlertsError({ loaded: true })).toBeUndefined();
+ expect(getAlertsError(undefined)).toBeUndefined();
+ });
+
+ it('should unwrap Error message from loadError', () => {
+ expect(getAlertsError({ loaded: true, loadError: new Error('fetch failed') })).toBe(
+ 'fetch failed',
+ );
+ });
+
+ it('should stringify string loadError values', () => {
+ expect(getAlertsError({ loaded: true, loadError: 'boom' })).toBe('boom');
+ });
+});
+
+describe('getFiringAlerts', () => {
+ it('should return an empty list while loading', () => {
+ expect(
+ getFiringAlerts({
+ alerts: [platformFiring],
+ loaded: false,
+ defaultAlertTenant: [AlertSource.Platform],
+ perspective: 'admin',
+ }),
+ ).toEqual([]);
+ });
+
+ it('should return an empty list when there is an error', () => {
+ expect(
+ getFiringAlerts({
+ alerts: [platformFiring],
+ alertsError: 'fetch failed',
+ loaded: true,
+ defaultAlertTenant: [AlertSource.Platform],
+ perspective: 'admin',
+ }),
+ ).toEqual([]);
+ });
+
+ it('should keep only firing alerts for the default tenant', () => {
+ const firing = getFiringAlerts({
+ alerts: [platformFiring, userFiring, pendingAlert],
+ loaded: true,
+ defaultAlertTenant: [AlertSource.Platform],
+ perspective: 'admin',
+ });
+
+ expect(firing).toHaveLength(1);
+ expect(firing[0].labels?.alertname).toBe('HighCPU');
+ });
+});
+
+describe('getAlertSummaryState', () => {
+ it('should report loading when useAlerts has not finished', () => {
+ expect(
+ getAlertSummaryState({
+ alerts: [platformFiring],
+ rules: [platformRule()],
+ rulesAlertLoading: { loaded: false },
+ defaultAlertTenant: [AlertSource.Platform],
+ perspective: 'admin',
+ }),
+ ).toEqual({
+ loading: true,
+ error: undefined,
+ rulesCount: 1,
+ firingAlertsCount: 0,
+ });
+ });
+
+ it('should report error state from useAlerts loadError', () => {
+ expect(
+ getAlertSummaryState({
+ alerts: [platformFiring],
+ rules: [platformRule(), platformRule()],
+ rulesAlertLoading: { loaded: true, loadError: new Error('unavailable') },
+ defaultAlertTenant: [AlertSource.Platform],
+ perspective: 'admin',
+ }),
+ ).toEqual({
+ loading: false,
+ error: 'unavailable',
+ rulesCount: 2,
+ firingAlertsCount: 0,
+ });
+ });
+
+ it('should report alert-rule and firing-alert counts when loaded', () => {
+ expect(
+ getAlertSummaryState({
+ alerts: [platformFiring, userFiring, pendingAlert],
+ rules: [platformRule(), platformRule(), platformRule()],
+ rulesAlertLoading: { loaded: true },
+ defaultAlertTenant: [AlertSource.Platform],
+ perspective: 'admin',
+ }),
+ ).toEqual({
+ loading: false,
+ error: undefined,
+ rulesCount: 3,
+ firingAlertsCount: 1,
+ });
+ });
+
+ it('should count only defaultAlertTenant rules among mixed platform and user rules', () => {
+ expect(
+ getAlertSummaryState({
+ alerts: [],
+ rules: [platformRule(), userRule(), platformRule(), userRule()],
+ rulesAlertLoading: { loaded: true },
+ defaultAlertTenant: [AlertSource.Platform],
+ perspective: 'admin',
+ }),
+ ).toEqual({
+ loading: false,
+ error: undefined,
+ rulesCount: 2,
+ firingAlertsCount: 0,
+ });
+ });
+
+ it('should fall back to zero when rules are undefined', () => {
+ expect(
+ getAlertSummaryState({
+ alerts: [],
+ rules: undefined,
+ rulesAlertLoading: { loaded: true },
+ defaultAlertTenant: [AlertSource.Platform],
+ perspective: 'admin',
+ }),
+ ).toEqual({
+ loading: false,
+ error: undefined,
+ rulesCount: 0,
+ firingAlertsCount: 0,
+ });
+ });
+});
diff --git a/web/src/features/mcp-overview/components/summary/dashboards-summary-card.spec.ts b/web/src/features/mcp-overview/components/summary/dashboards-summary-card.spec.ts
new file mode 100644
index 000000000..f9570b0d9
--- /dev/null
+++ b/web/src/features/mcp-overview/components/summary/dashboards-summary-card.spec.ts
@@ -0,0 +1,85 @@
+jest.mock('@tanstack/react-query', () => ({
+ useQuery: jest.fn(() => ({
+ isLoading: false,
+ error: null,
+ data: [],
+ })),
+}));
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('@/features/mcp-overview/components/summary/SummaryCard', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+
+jest.mock('@/shared/hooks/usePerspective', () => ({
+ usePerspective: jest.fn(() => ({ perspective: 'admin' })),
+ getDashboardsListUrl: jest.fn(() => '/monitoring/v2/dashboards'),
+}));
+
+jest.mock('@/shared/utils/perses-client', () => ({
+ fetchPersesDashboardsMetadata: jest.fn(),
+}));
+
+import { getDashboardsSummaryState } from '@/features/mcp-overview/components/summary/DashboardsSummaryCard';
+
+describe('getDashboardsSummaryState', () => {
+ it('should report loading from a mocked query result', () => {
+ expect(
+ getDashboardsSummaryState({
+ isLoading: true,
+ error: null,
+ data: undefined,
+ }),
+ ).toEqual({
+ loading: true,
+ count: 0,
+ error: undefined,
+ });
+ });
+
+ it('should report error message and zero count from a mocked query result', () => {
+ expect(
+ getDashboardsSummaryState({
+ isLoading: false,
+ error: new Error('perses unavailable'),
+ data: [{ metadata: { name: 'dash-1' } }],
+ }),
+ ).toEqual({
+ loading: false,
+ count: 0,
+ error: 'perses unavailable',
+ });
+ });
+
+ it('should stringify non-Error query errors', () => {
+ expect(
+ getDashboardsSummaryState({
+ isLoading: false,
+ error: 'network down',
+ data: null,
+ }),
+ ).toEqual({
+ loading: false,
+ count: 0,
+ error: 'network down',
+ });
+ });
+
+ it('should report dashboard count from a mocked query result', () => {
+ expect(
+ getDashboardsSummaryState({
+ isLoading: false,
+ error: null,
+ data: [{ metadata: { name: 'dash-1' } }, { metadata: { name: 'dash-2' } }],
+ }),
+ ).toEqual({
+ loading: false,
+ count: 2,
+ error: undefined,
+ });
+ });
+});
diff --git a/web/src/features/mcp-overview/pages/McpOverviewPage.tsx b/web/src/features/mcp-overview/pages/McpOverviewPage.tsx
new file mode 100644
index 000000000..681cd396b
--- /dev/null
+++ b/web/src/features/mcp-overview/pages/McpOverviewPage.tsx
@@ -0,0 +1,82 @@
+import { DocumentTitle, ListPageHeader } from '@openshift-console/dynamic-plugin-sdk';
+import { Alert, AlertActionCloseButton, PageBody, PageSection } from '@patternfly/react-core';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { FC } from 'react';
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import ObservabilityStackSummary from '@/features/mcp-overview/components/summary/ObservabilityStackSummary';
+import { DataTestIDs } from '@/shared/constants/data-test';
+import { MonitoringProvider } from '@/shared/contexts/MonitoringContext';
+
+const MCP_OVERVIEW_INFO_ALERT_DISMISSED = 'monitoring/mcp-overview/info-alert-dismissed';
+
+const McpOverviewPage: FC = () => {
+ const { t } = useTranslation(process.env.I18N_NAMESPACE);
+ const [showInfoAlert, setShowInfoAlert] = useState(
+ () => localStorage.getItem(MCP_OVERVIEW_INFO_ALERT_DISMISSED) !== 'true',
+ );
+
+ const dismissInfoAlert = () => {
+ localStorage.setItem(MCP_OVERVIEW_INFO_ALERT_DISMISSED, 'true');
+ setShowInfoAlert(false);
+ };
+
+ return (
+ <>
+ {t('Observability services')}
+
+
+ {showInfoAlert ? (
+
+
+ }
+ >
+ {t(
+ 'This hub reflects observability capabilities for the current cluster after Cluster Observability Operator installation. Status labels indicate enablement and configuration readiness—not live telemetry severity.',
+ )}
+
+
+ ) : null}
+
+
+
+
+ >
+ );
+};
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ retry: false,
+ },
+ },
+});
+
+export const MpCmoMcpOverviewPage: FC = () => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/web/src/features/perses-dashboards/components/PersesWrapper.tsx b/web/src/features/perses-dashboards/components/PersesWrapper.tsx
index e125c724a..079bf9324 100644
--- a/web/src/features/perses-dashboards/components/PersesWrapper.tsx
+++ b/web/src/features/perses-dashboards/components/PersesWrapper.tsx
@@ -51,13 +51,10 @@ import { StringParam, useQueryParam } from 'use-query-params';
import { OcpDatasourceApi } from '@/features/perses-dashboards/utils/datasource-api';
import { CachedDatasourceAPI } from '@/features/perses-dashboards/utils/perses/datasource-cache-api';
-import {
- PERSES_PROXY_BASE_PATH,
- useFetchPersesDashboard,
-} from '@/features/perses-dashboards/utils/perses-client';
import { LoadingBox } from '@/shared/console/console-shared/src/components/loading/LoadingBox';
import { QueryParams } from '@/shared/constants/query-params';
import { usePatternFlyTheme } from '@/shared/hooks/usePatternflyTheme';
+import { PERSES_PROXY_BASE_PATH, useFetchPersesDashboard } from '@/shared/utils/perses-client';
// Override eChart defaults with PatternFly colors.
const patternflyBlue100 = chart_color_blue_100.value;
diff --git a/web/src/features/perses-dashboards/hooks/useEditableProjects.ts b/web/src/features/perses-dashboards/hooks/useEditableProjects.ts
index c610c7ade..97ba72b73 100644
--- a/web/src/features/perses-dashboards/hooks/useEditableProjects.ts
+++ b/web/src/features/perses-dashboards/hooks/useEditableProjects.ts
@@ -3,10 +3,7 @@ import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useOcpProjects } from '@/features/perses-dashboards/hooks/useOcpProjects';
-import {
- PersesUserPermissions,
- useFetchPersesPermissions,
-} from '@/features/perses-dashboards/utils/perses-client';
+import { PersesUserPermissions, useFetchPersesPermissions } from '@/shared/utils/perses-client';
interface Projects {
editableProjects: string[] | undefined;
diff --git a/web/src/features/perses-dashboards/hooks/usePerses.ts b/web/src/features/perses-dashboards/hooks/usePerses.ts
index 06a01f536..18e7a0d28 100644
--- a/web/src/features/perses-dashboards/hooks/usePerses.ts
+++ b/web/src/features/perses-dashboards/hooks/usePerses.ts
@@ -2,12 +2,12 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { NumberParam, useQueryParam } from 'use-query-params';
+import { QueryParams } from '@/shared/constants/query-params';
import {
fetchPersesDashboardsByProject,
fetchPersesDashboardsMetadata,
fetchPersesProjects,
-} from '@/features/perses-dashboards/utils/perses-client';
-import { QueryParams } from '@/shared/constants/query-params';
+} from '@/shared/utils/perses-client';
export const usePerses = (project?: string | number) => {
const { t } = useTranslation(process.env.I18N_NAMESPACE);
diff --git a/web/src/features/perses-dashboards/utils/dashboard-api.ts b/web/src/features/perses-dashboards/utils/dashboard-api.ts
index c83618805..62cf31d7f 100644
--- a/web/src/features/perses-dashboards/utils/dashboard-api.ts
+++ b/web/src/features/perses-dashboards/utils/dashboard-api.ts
@@ -5,7 +5,7 @@ import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-
import { useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import buildURL from '@/features/perses-dashboards/utils/perses/url-builder';
-import { PERSES_PROXY_BASE_PATH } from '@/features/perses-dashboards/utils/perses-client';
+import { PERSES_PROXY_BASE_PATH } from '@/shared/utils/perses-client';
const resource = 'dashboards';
diff --git a/web/src/features/perses-dashboards/utils/migrate-api.ts b/web/src/features/perses-dashboards/utils/migrate-api.ts
index ea139fe23..a64512012 100644
--- a/web/src/features/perses-dashboards/utils/migrate-api.ts
+++ b/web/src/features/perses-dashboards/utils/migrate-api.ts
@@ -2,7 +2,7 @@ import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk';
import { DashboardResource } from '@perses-dev/core';
import { useMutation, UseMutationResult } from '@tanstack/react-query';
-import { PERSES_PROXY_BASE_PATH } from '@/features/perses-dashboards/utils/perses-client';
+import { PERSES_PROXY_BASE_PATH } from '@/shared/utils/perses-client';
const MIGRATE_ENDPOINT = `${PERSES_PROXY_BASE_PATH}/api/migrate`;
diff --git a/web/src/shared/constants/data-test.ts b/web/src/shared/constants/data-test.ts
index 270a128c3..a38476ea6 100644
--- a/web/src/shared/constants/data-test.ts
+++ b/web/src/shared/constants/data-test.ts
@@ -153,6 +153,17 @@ export const DataTestIDs = {
EndCell: 'incidents-details-end-cell',
AlertRuleLink: 'incidents-details-alert-rule-link',
},
+
+ // MCP Overview Page Test IDs
+ McpOverviewPage: {
+ InfoAlert: 'mcp-overview-info-alert',
+ InfoAlertClose: 'mcp-overview-info-alert-close',
+ SummarySection: 'mcp-overview-observability-stack-summary',
+ SummaryCard: 'mcp-overview-summary-card',
+ SummaryCardCount: 'mcp-overview-summary-card-count',
+ SummaryCardError: 'mcp-overview-summary-card-error',
+ SummaryCardLoading: 'mcp-overview-summary-card-loading',
+ },
};
export const FilterOUIAIDs = {
diff --git a/web/src/shared/hooks/useAlerts.ts b/web/src/shared/hooks/useAlerts.ts
index 45e61ad89..b893025c2 100644
--- a/web/src/shared/hooks/useAlerts.ts
+++ b/web/src/shared/hooks/useAlerts.ts
@@ -8,11 +8,7 @@ import {
import { useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
-import {
- alertingRuleSource,
- alertSource,
- getAdditionalSources,
-} from '@/features/alerts/components/AlertUtils';
+import { getAdditionalSources } from '@/features/alerts/components/AlertUtils';
import { usePoll } from '@/shared/console/utils/poll-hook';
import { useMonitoring } from '@/shared/hooks/useMonitoring';
import { useMonitoringNamespace } from '@/shared/hooks/useMonitoringNamespace';
@@ -20,6 +16,7 @@ import { getObserveState } from '@/shared/hooks/usePerspective';
import { AppDispatch } from '@/shared/store/actions';
import { MonitoringState } from '@/shared/store/store';
import { fetchAlertingData } from '@/shared/store/thunks';
+import { alertingRuleSource, alertSource } from '@/shared/utils/alert-utils';
import {
ALL_NAMESPACES_KEY,
buildPrometheusUrl,
diff --git a/web/src/features/alerts/pages/alerts-page/filter-alerts.spec.ts b/web/src/shared/utils/alert-utils.spec.ts
similarity index 87%
rename from web/src/features/alerts/pages/alerts-page/filter-alerts.spec.ts
rename to web/src/shared/utils/alert-utils.spec.ts
index 198621f48..5a40eaa3a 100644
--- a/web/src/features/alerts/pages/alerts-page/filter-alerts.spec.ts
+++ b/web/src/shared/utils/alert-utils.spec.ts
@@ -2,22 +2,44 @@ jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({
...jest.requireActual('@openshift-console/dynamic-plugin-sdk/lib/api/common-types'),
}));
-jest.mock('../../components/AlertUtils', () => ({
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- alertSource: (alert: any) =>
- alert.rule?.labels?.prometheus === 'openshift-monitoring/k8s' ? 'platform' : 'user',
-}));
-
-import { Alert, AlertStates } from '@openshift-console/dynamic-plugin-sdk';
+import { Alert, AlertStates, type Rule } from '@openshift-console/dynamic-plugin-sdk';
+import { AlertSource } from '@/shared/types/types';
import {
AggregatedAlertFilters,
AlertFilterOptions,
-} from '@/features/alerts/pages/alerts-page/AlertsPage';
-import { filterAlerts } from '@/features/alerts/pages/alerts-page/filter-alerts';
-import { AlertSource } from '@/shared/types/types';
+ alertingRuleSource,
+ filterAlerts,
+} from '@/shared/utils/alert-utils';
import { ALL_NAMESPACES_KEY } from '@/shared/utils/utils';
+const makeRule = (overrides: Partial = {}): Rule =>
+ ({
+ alerts: [],
+ labels: {},
+ ...overrides,
+ }) as unknown as Rule;
+
+describe('alertingRuleSource', () => {
+ it('should return Platform for prometheus source with platform label', () => {
+ const rule = makeRule({
+ sourceId: 'prometheus',
+ labels: { prometheus: 'openshift-monitoring/k8s' },
+ });
+ expect(alertingRuleSource(rule)).toBe(AlertSource.Platform);
+ });
+
+ it('should return User for prometheus source without platform label', () => {
+ const rule = makeRule({ sourceId: 'prometheus' });
+ expect(alertingRuleSource(rule)).toBe(AlertSource.User);
+ });
+
+ it('should return the custom source ID as-is', () => {
+ const rule = makeRule({ sourceId: 'custom-datasource' });
+ expect(alertingRuleSource(rule)).toBe('custom-datasource');
+ });
+});
+
const emptyFilters: AggregatedAlertFilters = {
[AlertFilterOptions.NAME]: '',
[AlertFilterOptions.STATE]: [],
diff --git a/web/src/features/alerts/pages/alerts-page/filter-alerts.ts b/web/src/shared/utils/alert-utils.ts
similarity index 73%
rename from web/src/features/alerts/pages/alerts-page/filter-alerts.ts
rename to web/src/shared/utils/alert-utils.ts
index 70acb7c44..53b2a2782 100644
--- a/web/src/features/alerts/pages/alerts-page/filter-alerts.ts
+++ b/web/src/shared/utils/alert-utils.ts
@@ -1,13 +1,39 @@
-import { Alert } from '@openshift-console/dynamic-plugin-sdk';
+import { Alert, Rule } from '@openshift-console/dynamic-plugin-sdk';
-import { alertSource } from '@/features/alerts/components/AlertUtils';
-import {
- AggregatedAlertFilters,
- AlertFilterOptions,
-} from '@/features/alerts/pages/alerts-page/AlertsPage';
import { Perspective } from '@/shared/store/actions';
+import { AlertSource } from '@/shared/types/types';
import { alertState, ALL_NAMESPACES_KEY, fuzzyCaseInsensitive } from '@/shared/utils/utils';
+export const enum AlertFilterOptions {
+ NAME = 'name',
+ STATE = 'alert-state',
+ SEVERITY = 'alert-severity',
+ LABEL = 'label',
+ SOURCE = 'alert-source',
+ CLUSTER = 'alert-cluster',
+}
+
+export interface AggregatedAlertFilters {
+ [AlertFilterOptions.NAME]: string;
+ [AlertFilterOptions.STATE]: string[];
+ [AlertFilterOptions.SEVERITY]: string[];
+ [AlertFilterOptions.LABEL]: string;
+ [AlertFilterOptions.SOURCE]?: AlertSource[];
+ [AlertFilterOptions.CLUSTER]?: string[];
+}
+
+export const alertingRuleSource = (rule: Rule): AlertSource | string => {
+ if (rule.sourceId === undefined || rule.sourceId === 'prometheus') {
+ return rule.labels?.prometheus === 'openshift-monitoring/k8s'
+ ? AlertSource.Platform
+ : AlertSource.User;
+ }
+
+ return rule.sourceId;
+};
+
+export const alertSource = (alert: Alert): AlertSource | string => alertingRuleSource(alert.rule);
+
export const filterAlerts = (
alerts: Alert[],
selectedFilters: AggregatedAlertFilters,
diff --git a/web/src/shared/utils/perses-client.spec.ts b/web/src/shared/utils/perses-client.spec.ts
new file mode 100644
index 000000000..f4fce7414
--- /dev/null
+++ b/web/src/shared/utils/perses-client.spec.ts
@@ -0,0 +1,140 @@
+jest.mock('@openshift-console/dynamic-plugin-sdk', () => ({
+ consoleFetchJSON: jest.fn(),
+}));
+
+jest.mock('@tanstack/react-query', () => ({
+ useQuery: jest.fn(),
+}));
+
+jest.mock('use-query-params', () => ({
+ NumberParam: {},
+ useQueryParam: jest.fn(() => [undefined]),
+}));
+
+import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk';
+import { useQuery } from '@tanstack/react-query';
+
+import {
+ fetchPersesDashboard,
+ fetchPersesDashboardsByProject,
+ fetchPersesDashboardsMetadata,
+ fetchPersesProjects,
+ fetchPersesUserPermissions,
+ PERSES_PROXY_BASE_PATH,
+ useFetchPersesPermissions,
+} from '@/shared/utils/perses-client';
+
+const mockConsoleFetchJSON = consoleFetchJSON as jest.MockedFunction;
+const mockUseQuery = useQuery as jest.MockedFunction;
+
+describe('perses-client URL construction', () => {
+ beforeEach(() => {
+ mockConsoleFetchJSON.mockReset();
+ mockConsoleFetchJSON.mockResolvedValue([]);
+ });
+
+ it('fetchPersesDashboardsMetadata uses the dashboards metadata path', async () => {
+ await fetchPersesDashboardsMetadata();
+
+ expect(mockConsoleFetchJSON).toHaveBeenCalledWith(
+ `${PERSES_PROXY_BASE_PATH}/api/v1/dashboards`,
+ );
+ });
+
+ it('fetchPersesDashboardsByProject encodes the project query param', async () => {
+ await fetchPersesDashboardsByProject('my project/v1');
+
+ expect(mockConsoleFetchJSON).toHaveBeenCalledWith(
+ `${PERSES_PROXY_BASE_PATH}/api/v1/dashboards?project=my%20project%2Fv1`,
+ );
+ });
+
+ it('fetchPersesProjects uses the projects path', async () => {
+ await fetchPersesProjects();
+
+ expect(mockConsoleFetchJSON).toHaveBeenCalledWith(`${PERSES_PROXY_BASE_PATH}/api/v1/projects`);
+ });
+
+ it('fetchPersesUserPermissions encodes the username path segment', async () => {
+ await fetchPersesUserPermissions('user/name@example.com');
+
+ expect(mockConsoleFetchJSON).toHaveBeenCalledWith(
+ `${PERSES_PROXY_BASE_PATH}/api/v1/users/user%2Fname%40example.com/permissions`,
+ );
+ });
+
+ it('fetchPersesDashboard builds the project and dashboard path', async () => {
+ await fetchPersesDashboard('observability', 'cluster-health');
+
+ expect(mockConsoleFetchJSON).toHaveBeenCalledWith(
+ `${PERSES_PROXY_BASE_PATH}/api/v1/projects/observability/dashboards/cluster-health`,
+ );
+ });
+});
+
+describe('perses-client error propagation', () => {
+ beforeEach(() => {
+ mockConsoleFetchJSON.mockReset();
+ });
+
+ it.each([
+ ['fetchPersesDashboardsMetadata', () => fetchPersesDashboardsMetadata()],
+ ['fetchPersesDashboardsByProject', () => fetchPersesDashboardsByProject('project')],
+ ['fetchPersesProjects', () => fetchPersesProjects()],
+ ['fetchPersesUserPermissions', () => fetchPersesUserPermissions('user')],
+ ['fetchPersesDashboard', () => fetchPersesDashboard('project', 'dashboard')],
+ ])('%s propagates consoleFetchJSON errors unchanged', async (_name, invoke) => {
+ const fetchError = new Error('perses unavailable');
+ mockConsoleFetchJSON.mockRejectedValue(fetchError);
+
+ await expect(invoke()).rejects.toBe(fetchError);
+ });
+});
+
+describe('useFetchPersesPermissions', () => {
+ beforeEach(() => {
+ mockConsoleFetchJSON.mockReset();
+ mockConsoleFetchJSON.mockResolvedValue({});
+ mockUseQuery.mockReset();
+ mockUseQuery.mockImplementation((options: unknown) => {
+ const queryOptions = options as {
+ enabled?: boolean;
+ queryFn?: () => unknown;
+ };
+ if (queryOptions.enabled) {
+ void queryOptions.queryFn?.();
+ }
+ return {
+ isLoading: false,
+ error: null,
+ data: undefined,
+ } as ReturnType;
+ });
+ });
+
+ it('does not invoke consoleFetchJSON when username is empty', () => {
+ useFetchPersesPermissions('');
+
+ expect(mockUseQuery).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enabled: false,
+ queryKey: ['perses-user-permissions', ''],
+ }),
+ );
+ expect(mockConsoleFetchJSON).not.toHaveBeenCalled();
+ });
+
+ it('invokes consoleFetchJSON when username is provided', () => {
+ useFetchPersesPermissions('kubeadmin');
+
+ expect(mockUseQuery).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enabled: true,
+ queryKey: ['perses-user-permissions', 'kubeadmin'],
+ }),
+ );
+ expect(mockConsoleFetchJSON).toHaveBeenCalledWith(
+ `${PERSES_PROXY_BASE_PATH}/api/v1/users/kubeadmin/permissions`,
+ );
+ });
+});
diff --git a/web/src/features/perses-dashboards/utils/perses-client.ts b/web/src/shared/utils/perses-client.ts
similarity index 97%
rename from web/src/features/perses-dashboards/utils/perses-client.ts
rename to web/src/shared/utils/perses-client.ts
index 4bc9732c1..3c6055bfb 100644
--- a/web/src/features/perses-dashboards/utils/perses-client.ts
+++ b/web/src/shared/utils/perses-client.ts
@@ -1,5 +1,5 @@
import { consoleFetchJSON } from '@openshift-console/dynamic-plugin-sdk';
-import { DashboardResource, ProjectResource } from '@perses-dev/core';
+import type { DashboardResource, ProjectResource } from '@perses-dev/core';
import { useQuery } from '@tanstack/react-query';
import { NumberParam, useQueryParam } from 'use-query-params';