From 6625f4149b1097045c6b8e7a63fdd762729c1d53 Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Date: Mon, 3 Aug 2026 09:02:17 -0400 Subject: [PATCH] OU-1212: Create initial observability landing page with summary cards --- Makefile | 2 +- README.md | 5 +- cmd/plugin-backend.go | 2 +- config/mcp-overview.patch.json | 30 ++ pkg/server/plugin_handler.go | 1 + pkg/server/server.go | 1 + web/cypress/component/SummaryCard.cy.tsx | 76 ++++ web/cypress/e2e/monitoring/00.bvt_admin.cy.ts | 10 +- .../regression/04.reg_services_admin.cy.ts | 30 ++ .../monitoring/04.reg_mcp-overview.cy.ts | 87 ++++ web/cypress/views/mcp-overview-page.ts | 127 ++++++ web/cypress/views/overview-page.ts | 10 +- web/locales/en/plugin__monitoring-plugin.json | 422 +++++++++--------- web/package.json | 1 + .../features/alerts/components/AlertUtils.tsx | 11 - .../alerts/pages/AlertRulesDetailsPage.tsx | 2 +- .../alerts/pages/AlertsDetailsPage.tsx | 2 +- .../pages/alert-rules-page/AlertRulesPage.tsx | 2 +- .../pages/alert-rules-page/filter-rules.ts | 2 +- .../alerts-page/AggregateAlertTableRow.tsx | 3 +- .../pages/alerts-page/AlertTableRow.tsx | 2 +- .../alerts/pages/alerts-page/AlertsPage.tsx | 24 +- web/src/features/mcp-overview/OWNERS | 4 + .../components/summary/AlertSummaryCards.tsx | 131 ++++++ .../summary/DashboardsSummaryCard.tsx | 57 +++ .../components/summary/MetricsSummaryCard.tsx | 70 +++ .../summary/ObservabilityStackSummary.tsx | 46 ++ .../components/summary/SummaryCard.tsx | 64 +++ .../components/summary/TargetsSummaryCard.tsx | 68 +++ .../summary/alert-summary-cards.spec.ts | 242 ++++++++++ .../summary/dashboards-summary-card.spec.ts | 85 ++++ .../mcp-overview/pages/McpOverviewPage.tsx | 82 ++++ .../components/PersesWrapper.tsx | 5 +- .../hooks/useEditableProjects.ts | 5 +- .../perses-dashboards/hooks/usePerses.ts | 4 +- .../perses-dashboards/utils/dashboard-api.ts | 2 +- .../perses-dashboards/utils/migrate-api.ts | 2 +- web/src/shared/constants/data-test.ts | 11 + web/src/shared/hooks/useAlerts.ts | 7 +- .../utils/alert-utils.spec.ts} | 42 +- .../utils/alert-utils.ts} | 38 +- web/src/shared/utils/perses-client.spec.ts | 140 ++++++ .../utils/perses-client.ts | 2 +- 43 files changed, 1668 insertions(+), 291 deletions(-) create mode 100644 config/mcp-overview.patch.json create mode 100644 web/cypress/component/SummaryCard.cy.tsx create mode 100644 web/cypress/e2e/monitoring/regression/04.reg_services_admin.cy.ts create mode 100644 web/cypress/support/monitoring/04.reg_mcp-overview.cy.ts create mode 100644 web/cypress/views/mcp-overview-page.ts create mode 100644 web/src/features/mcp-overview/OWNERS create mode 100644 web/src/features/mcp-overview/components/summary/AlertSummaryCards.tsx create mode 100644 web/src/features/mcp-overview/components/summary/DashboardsSummaryCard.tsx create mode 100644 web/src/features/mcp-overview/components/summary/MetricsSummaryCard.tsx create mode 100644 web/src/features/mcp-overview/components/summary/ObservabilityStackSummary.tsx create mode 100644 web/src/features/mcp-overview/components/summary/SummaryCard.tsx create mode 100644 web/src/features/mcp-overview/components/summary/TargetsSummaryCard.tsx create mode 100644 web/src/features/mcp-overview/components/summary/alert-summary-cards.spec.ts create mode 100644 web/src/features/mcp-overview/components/summary/dashboards-summary-card.spec.ts create mode 100644 web/src/features/mcp-overview/pages/McpOverviewPage.tsx rename web/src/{features/alerts/pages/alerts-page/filter-alerts.spec.ts => shared/utils/alert-utils.spec.ts} (87%) rename web/src/{features/alerts/pages/alerts-page/filter-alerts.ts => shared/utils/alert-utils.ts} (73%) create mode 100644 web/src/shared/utils/perses-client.spec.ts rename web/src/{features/perses-dashboards => shared}/utils/perses-client.ts (97%) 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';