From c3a54f81df7e66177f7ebb9ba58ef5483b01480c Mon Sep 17 00:00:00 2001 From: Keith Chong Date: Sun, 18 Oct 2026 21:49:06 -0400 Subject: [PATCH] Create Dashboard with Sync/Health, Sync and Recon. Cards (#11387) Signed-off-by: Keith Chong --- __mocks__/patternfly-react-core.tsx | 31 ++ console-extensions.json | 27 ++ i18next-parser.config.js | 2 +- locales/en/plugin__gitops-plugin.json | 37 ++ locales/es/plugin__gitops-plugin.json | 39 +- locales/fr/plugin__gitops-plugin.json | 39 +- locales/ja/plugin__gitops-plugin.json | 37 ++ locales/ko/plugin__gitops-plugin.json | 37 ++ locales/zh/plugin__gitops-plugin.json | 37 ++ package.json | 17 + plugin-metadata.ts | 1 + pnpm-lock.yaml | 387 +++++++++++++++++- .../overview/DashboardOverviewPage.scss | 64 +++ .../overview/DashboardOverviewPage.tsx | 84 ++++ .../components/overview/chartHelpers.test.tsx | 210 ++++++++++ .../components/overview/chartHelpers.tsx | 308 ++++++++++++++ .../health-status/HealthStatusCard.tsx | 222 ++++++++++ .../OperationalMetricsCard.tsx | 335 +++++++++++++++ .../reconciliation/ReconciliationCard.tsx | 184 +++++++++ .../sync-activity/SyncActivityCard.tsx | 296 ++++++++++++++ .../overview/sync-status/SyncStatusCard.tsx | 159 +++++++ src/gitops/utils/prometheusChartData.test.ts | 33 ++ src/gitops/utils/prometheusChartData.ts | 46 +++ 23 files changed, 2627 insertions(+), 5 deletions(-) create mode 100644 src/gitops/components/overview/DashboardOverviewPage.scss create mode 100644 src/gitops/components/overview/DashboardOverviewPage.tsx create mode 100644 src/gitops/components/overview/chartHelpers.test.tsx create mode 100644 src/gitops/components/overview/chartHelpers.tsx create mode 100644 src/gitops/components/overview/health-status/HealthStatusCard.tsx create mode 100644 src/gitops/components/overview/operational-metrics/OperationalMetricsCard.tsx create mode 100644 src/gitops/components/overview/reconciliation/ReconciliationCard.tsx create mode 100644 src/gitops/components/overview/sync-activity/SyncActivityCard.tsx create mode 100644 src/gitops/components/overview/sync-status/SyncStatusCard.tsx create mode 100644 src/gitops/utils/prometheusChartData.test.ts create mode 100644 src/gitops/utils/prometheusChartData.ts diff --git a/__mocks__/patternfly-react-core.tsx b/__mocks__/patternfly-react-core.tsx index 2aaa10ea7..837032e07 100644 --- a/__mocks__/patternfly-react-core.tsx +++ b/__mocks__/patternfly-react-core.tsx @@ -80,3 +80,34 @@ export const LabelGroup: React.FC = ({ children, className, numLabels }) => export const Icon: React.FC = ({ children, size }) => ( {children} ); + +export const EmptyState: React.FC = ({ + children, + className, + titleText, + headingLevel: Tag = 'h2', + icon: EmptyStateIconComponent, + status, + variant, + ...rest +}) => ( +
+ {EmptyStateIconComponent && } + {titleText && {titleText}} + {children} +
+); + +export const EmptyStateBody: React.FC = ({ children }) => ( +
{children}
+); + +export const Spinner: React.FC = ({ size, ...rest }) => ( + +); diff --git a/console-extensions.json b/console-extensions.json index 873985fbd..6fe673a01 100644 --- a/console-extensions.json +++ b/console-extensions.json @@ -317,6 +317,33 @@ } } }, + { + "type": "console.navigation/href", + "properties": { + "id": "openshift-gitops-overview", + "href": "/gitops/overview", + "name": "Dashboard", + "perspective": "admin", + "section": "gitops-navigation-section", + "namespaced": true + } + }, + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": "/gitops/overview", + "component": { "$codeRef": "DashboardOverviewPage.GitOpsOverviewRedirect" } + } + }, + { + "type": "console.page/route", + "properties": { + "exact": true, + "path": ["/gitops/overview/all-namespaces", "/gitops/overview/ns/:ns"], + "component": { "$codeRef": "DashboardOverviewPage" } + } + }, { "type": "console.navigation/resource-ns", "properties": { diff --git a/i18next-parser.config.js b/i18next-parser.config.js index dad3281a2..d9d6e3dd5 100644 --- a/i18next-parser.config.js +++ b/i18next-parser.config.js @@ -1,7 +1,7 @@ module.exports = { createOldCatalogs: false, keySeparator: false, - locales: ['en', 'ja', 'ko', 'zh'], + locales: ['en', 'ja', 'ko', 'zh', 'es', 'fr'], namespaceSeparator: '~', reactNamespace: false, useKeysAsDefaultValue: true, diff --git a/locales/en/plugin__gitops-plugin.json b/locales/en/plugin__gitops-plugin.json index a11904d03..a900480be 100644 --- a/locales/en/plugin__gitops-plugin.json +++ b/locales/en/plugin__gitops-plugin.json @@ -159,6 +159,43 @@ "New Version": "New Version", "Apps Updated": "Apps Updated", "Updated At": "Updated At", + "No datapoints found.": "No datapoints found.", + "Loading...": "Loading...", + "GitOps Overview": "GitOps Overview", + "Healthy": "Healthy", + "Progressing": "Progressing", + "Degraded": "Degraded", + "Missing": "Missing", + "Suspended": "Suspended", + "Health status": "Health status", + "Unable to retrieve applications": "Unable to retrieve applications", + "Error retrieving applications: ": "Error retrieving applications: ", + "Error fetching metric data. Click to view metrics dashboard.": "Error fetching metric data. Click to view metrics dashboard.", + "Operational metrics": "Operational metrics", + "Last update time. Updated every 10 seconds.": "Last update time. Updated every 10 seconds.", + "Successful syncs": "Successful syncs", + "Number of successful syncs": "Number of successful syncs", + "Failed syncs": "Failed syncs", + "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.": "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.", + "Orphaned resources": "Orphaned resources", + "Number of orphaned resources": "Number of orphaned resources", + "Apps with Autosync enabled": "Apps with Autosync enabled", + "Number of apps with autosync enabled": "Number of apps with autosync enabled", + "Apps with Autosync disabled": "Apps with Autosync disabled", + "Number of apps with autosync disabled": "Number of apps with autosync disabled", + "Repo Queue": "Repo Queue", + "Number of repos in the queue": "Number of repos in the queue", + "Git fetch failures": "Git fetch failures", + "Number of git fetch failures": "Number of git fetch failures", + "Application Reconciliation Performance (24 hours)": "Application Reconciliation Performance (24 hours)", + "Unable to retrieve metrics data": "Unable to retrieve metrics data", + "Application reconciliation performance over time": "Application reconciliation performance over time", + "View in Observe/Metrics": "View in Observe/Metrics", + "Syncs (24h sliding window)": "Syncs (24h sliding window)", + "Synced": "Synced", + "Out of Sync": "Out of Sync", + "Application Sync Activity (24 hours)": "Application Sync Activity (24 hours)", + "Application sync activity over time": "Application sync activity over time", "Server": "Server", "Deny": "Deny", "Allow": "Allow", diff --git a/locales/es/plugin__gitops-plugin.json b/locales/es/plugin__gitops-plugin.json index 8350ce242..045946cfe 100644 --- a/locales/es/plugin__gitops-plugin.json +++ b/locales/es/plugin__gitops-plugin.json @@ -145,6 +145,7 @@ "Apps": "Aplicaciones", "Images": "Imágenes", "Last Checked": "Última comprobación", + "Labels": "Etiquetas", "Has Apps": "Tiene aplicaciones", "No Apps": "Sin aplicaciones", "Not Ready": "No está listo", @@ -158,6 +159,43 @@ "New Version": "Nueva versión", "Apps Updated": "Aplicaciones actualizadas", "Updated At": "Actualizado a las", + "No datapoints found.": "No datapoints found.", + "Loading...": "Loading...", + "GitOps Overview": "GitOps Overview", + "Healthy": "Healthy", + "Progressing": "Progressing", + "Degraded": "Degraded", + "Missing": "Missing", + "Suspended": "Suspended", + "Health status": "Health status", + "Unable to retrieve applications": "Unable to retrieve applications", + "Error retrieving applications: ": "Error retrieving applications: ", + "Error fetching metric data. Click to view metrics dashboard.": "Error fetching metric data. Click to view metrics dashboard.", + "Operational metrics": "Operational metrics", + "Last update time. Updated every 10 seconds.": "Last update time. Updated every 10 seconds.", + "Successful syncs": "Successful syncs", + "Number of successful syncs": "Number of successful syncs", + "Failed syncs": "Failed syncs", + "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.": "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.", + "Orphaned resources": "Orphaned resources", + "Number of orphaned resources": "Number of orphaned resources", + "Apps with Autosync enabled": "Apps with Autosync enabled", + "Number of apps with autosync enabled": "Number of apps with autosync enabled", + "Apps with Autosync disabled": "Apps with Autosync disabled", + "Number of apps with autosync disabled": "Number of apps with autosync disabled", + "Repo Queue": "Repo Queue", + "Number of repos in the queue": "Number of repos in the queue", + "Git fetch failures": "Git fetch failures", + "Number of git fetch failures": "Number of git fetch failures", + "Application Reconciliation Performance (24 hours)": "Application Reconciliation Performance (24 hours)", + "Unable to retrieve metrics data": "Unable to retrieve metrics data", + "Application reconciliation performance over time": "Application reconciliation performance over time", + "View in Observe/Metrics": "View in Observe/Metrics", + "Syncs (24h sliding window)": "Syncs (24h sliding window)", + "Synced": "Synced", + "Out of Sync": "Out of Sync", + "Application Sync Activity (24 hours)": "Application Sync Activity (24 hours)", + "Application sync activity over time": "Application sync activity over time", "Server": "Servidor", "Deny": "Denegar", "Allow": "Permitir", @@ -217,7 +255,6 @@ "There was an error retrieving App Projects. Check your connection and reload the page.": "Se produjo un error al recuperar los proyectos de aplicaciones. Compruebe su conexión y vuelva a cargar la página.", "AppProjects": "AppProjects", "Create AppProject": "Crear AppProject", - "Labels": "Etiquetas", "Last Updated": "Última actualización", "Has Description": "Tiene descripción", "No Description": "Sin descripción", diff --git a/locales/fr/plugin__gitops-plugin.json b/locales/fr/plugin__gitops-plugin.json index 7684479e0..de372a49d 100644 --- a/locales/fr/plugin__gitops-plugin.json +++ b/locales/fr/plugin__gitops-plugin.json @@ -145,6 +145,7 @@ "Apps": "Applications", "Images": "Images", "Last Checked": "Dernière vérification", + "Labels": "Étiquettes", "Has Apps": "Possède des applications", "No Apps": "Aucune application", "Not Ready": "Pas prêt", @@ -158,6 +159,43 @@ "New Version": "Nouvelle version", "Apps Updated": "Applications mises à jour", "Updated At": "Mise à jour le", + "No datapoints found.": "No datapoints found.", + "Loading...": "Loading...", + "GitOps Overview": "GitOps Overview", + "Healthy": "Healthy", + "Progressing": "Progressing", + "Degraded": "Degraded", + "Missing": "Missing", + "Suspended": "Suspended", + "Health status": "Health status", + "Unable to retrieve applications": "Unable to retrieve applications", + "Error retrieving applications: ": "Error retrieving applications: ", + "Error fetching metric data. Click to view metrics dashboard.": "Error fetching metric data. Click to view metrics dashboard.", + "Operational metrics": "Operational metrics", + "Last update time. Updated every 10 seconds.": "Last update time. Updated every 10 seconds.", + "Successful syncs": "Successful syncs", + "Number of successful syncs": "Number of successful syncs", + "Failed syncs": "Failed syncs", + "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.": "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.", + "Orphaned resources": "Orphaned resources", + "Number of orphaned resources": "Number of orphaned resources", + "Apps with Autosync enabled": "Apps with Autosync enabled", + "Number of apps with autosync enabled": "Number of apps with autosync enabled", + "Apps with Autosync disabled": "Apps with Autosync disabled", + "Number of apps with autosync disabled": "Number of apps with autosync disabled", + "Repo Queue": "Repo Queue", + "Number of repos in the queue": "Number of repos in the queue", + "Git fetch failures": "Git fetch failures", + "Number of git fetch failures": "Number of git fetch failures", + "Application Reconciliation Performance (24 hours)": "Application Reconciliation Performance (24 hours)", + "Unable to retrieve metrics data": "Unable to retrieve metrics data", + "Application reconciliation performance over time": "Application reconciliation performance over time", + "View in Observe/Metrics": "View in Observe/Metrics", + "Syncs (24h sliding window)": "Syncs (24h sliding window)", + "Synced": "Synced", + "Out of Sync": "Out of Sync", + "Application Sync Activity (24 hours)": "Application Sync Activity (24 hours)", + "Application sync activity over time": "Application sync activity over time", "Server": "Serveur", "Deny": "Refuser", "Allow": "Autoriser", @@ -217,7 +255,6 @@ "There was an error retrieving App Projects. Check your connection and reload the page.": "Une erreur s'est produite lors de la récupération des projets d'application. Vérifiez votre connexion et rechargez la page.", "AppProjects": "Projets d'application", "Create AppProject": "Créer un projet d'application", - "Labels": "Étiquettes", "Last Updated": "Dernière mise à jour", "Has Description": "Contient une description", "No Description": "Aucune description", diff --git a/locales/ja/plugin__gitops-plugin.json b/locales/ja/plugin__gitops-plugin.json index 9e39988ee..b5aedb4c1 100644 --- a/locales/ja/plugin__gitops-plugin.json +++ b/locales/ja/plugin__gitops-plugin.json @@ -159,6 +159,43 @@ "New Version": "新バージョン", "Apps Updated": "更新されたアプリケーション", "Updated At": "更新日時", + "No datapoints found.": "No datapoints found.", + "Loading...": "Loading...", + "GitOps Overview": "GitOps Overview", + "Healthy": "Healthy", + "Progressing": "Progressing", + "Degraded": "Degraded", + "Missing": "Missing", + "Suspended": "Suspended", + "Health status": "Health status", + "Unable to retrieve applications": "Unable to retrieve applications", + "Error retrieving applications: ": "Error retrieving applications: ", + "Error fetching metric data. Click to view metrics dashboard.": "Error fetching metric data. Click to view metrics dashboard.", + "Operational metrics": "Operational metrics", + "Last update time. Updated every 10 seconds.": "Last update time. Updated every 10 seconds.", + "Successful syncs": "Successful syncs", + "Number of successful syncs": "Number of successful syncs", + "Failed syncs": "Failed syncs", + "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.": "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.", + "Orphaned resources": "Orphaned resources", + "Number of orphaned resources": "Number of orphaned resources", + "Apps with Autosync enabled": "Apps with Autosync enabled", + "Number of apps with autosync enabled": "Number of apps with autosync enabled", + "Apps with Autosync disabled": "Apps with Autosync disabled", + "Number of apps with autosync disabled": "Number of apps with autosync disabled", + "Repo Queue": "Repo Queue", + "Number of repos in the queue": "Number of repos in the queue", + "Git fetch failures": "Git fetch failures", + "Number of git fetch failures": "Number of git fetch failures", + "Application Reconciliation Performance (24 hours)": "Application Reconciliation Performance (24 hours)", + "Unable to retrieve metrics data": "Unable to retrieve metrics data", + "Application reconciliation performance over time": "Application reconciliation performance over time", + "View in Observe/Metrics": "View in Observe/Metrics", + "Syncs (24h sliding window)": "Syncs (24h sliding window)", + "Synced": "Synced", + "Out of Sync": "Out of Sync", + "Application Sync Activity (24 hours)": "Application Sync Activity (24 hours)", + "Application sync activity over time": "Application sync activity over time", "Server": "サーバー", "Deny": "拒否", "Allow": "許可", diff --git a/locales/ko/plugin__gitops-plugin.json b/locales/ko/plugin__gitops-plugin.json index 5319036c3..70f25fb00 100644 --- a/locales/ko/plugin__gitops-plugin.json +++ b/locales/ko/plugin__gitops-plugin.json @@ -159,6 +159,43 @@ "New Version": "새 버전", "Apps Updated": "업데이트된 애플리케이션", "Updated At": "업데이트 시간", + "No datapoints found.": "No datapoints found.", + "Loading...": "Loading...", + "GitOps Overview": "GitOps Overview", + "Healthy": "Healthy", + "Progressing": "Progressing", + "Degraded": "Degraded", + "Missing": "Missing", + "Suspended": "Suspended", + "Health status": "Health status", + "Unable to retrieve applications": "Unable to retrieve applications", + "Error retrieving applications: ": "Error retrieving applications: ", + "Error fetching metric data. Click to view metrics dashboard.": "Error fetching metric data. Click to view metrics dashboard.", + "Operational metrics": "Operational metrics", + "Last update time. Updated every 10 seconds.": "Last update time. Updated every 10 seconds.", + "Successful syncs": "Successful syncs", + "Number of successful syncs": "Number of successful syncs", + "Failed syncs": "Failed syncs", + "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.": "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.", + "Orphaned resources": "Orphaned resources", + "Number of orphaned resources": "Number of orphaned resources", + "Apps with Autosync enabled": "Apps with Autosync enabled", + "Number of apps with autosync enabled": "Number of apps with autosync enabled", + "Apps with Autosync disabled": "Apps with Autosync disabled", + "Number of apps with autosync disabled": "Number of apps with autosync disabled", + "Repo Queue": "Repo Queue", + "Number of repos in the queue": "Number of repos in the queue", + "Git fetch failures": "Git fetch failures", + "Number of git fetch failures": "Number of git fetch failures", + "Application Reconciliation Performance (24 hours)": "Application Reconciliation Performance (24 hours)", + "Unable to retrieve metrics data": "Unable to retrieve metrics data", + "Application reconciliation performance over time": "Application reconciliation performance over time", + "View in Observe/Metrics": "View in Observe/Metrics", + "Syncs (24h sliding window)": "Syncs (24h sliding window)", + "Synced": "Synced", + "Out of Sync": "Out of Sync", + "Application Sync Activity (24 hours)": "Application Sync Activity (24 hours)", + "Application sync activity over time": "Application sync activity over time", "Server": "서버", "Deny": "거부", "Allow": "허용", diff --git a/locales/zh/plugin__gitops-plugin.json b/locales/zh/plugin__gitops-plugin.json index 3db9df646..9806d55bf 100644 --- a/locales/zh/plugin__gitops-plugin.json +++ b/locales/zh/plugin__gitops-plugin.json @@ -159,6 +159,43 @@ "New Version": "新版本", "Apps Updated": "已更新的应用程序", "Updated At": "更新时间", + "No datapoints found.": "No datapoints found.", + "Loading...": "Loading...", + "GitOps Overview": "GitOps Overview", + "Healthy": "Healthy", + "Progressing": "Progressing", + "Degraded": "Degraded", + "Missing": "Missing", + "Suspended": "Suspended", + "Health status": "Health status", + "Unable to retrieve applications": "Unable to retrieve applications", + "Error retrieving applications: ": "Error retrieving applications: ", + "Error fetching metric data. Click to view metrics dashboard.": "Error fetching metric data. Click to view metrics dashboard.", + "Operational metrics": "Operational metrics", + "Last update time. Updated every 10 seconds.": "Last update time. Updated every 10 seconds.", + "Successful syncs": "Successful syncs", + "Number of successful syncs": "Number of successful syncs", + "Failed syncs": "Failed syncs", + "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.": "Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.", + "Orphaned resources": "Orphaned resources", + "Number of orphaned resources": "Number of orphaned resources", + "Apps with Autosync enabled": "Apps with Autosync enabled", + "Number of apps with autosync enabled": "Number of apps with autosync enabled", + "Apps with Autosync disabled": "Apps with Autosync disabled", + "Number of apps with autosync disabled": "Number of apps with autosync disabled", + "Repo Queue": "Repo Queue", + "Number of repos in the queue": "Number of repos in the queue", + "Git fetch failures": "Git fetch failures", + "Number of git fetch failures": "Number of git fetch failures", + "Application Reconciliation Performance (24 hours)": "Application Reconciliation Performance (24 hours)", + "Unable to retrieve metrics data": "Unable to retrieve metrics data", + "Application reconciliation performance over time": "Application reconciliation performance over time", + "View in Observe/Metrics": "View in Observe/Metrics", + "Syncs (24h sliding window)": "Syncs (24h sliding window)", + "Synced": "Synced", + "Out of Sync": "Out of Sync", + "Application Sync Activity (24 hours)": "Application Sync Activity (24 hours)", + "Application sync activity over time": "Application sync activity over time", "Server": "服务器", "Deny": "拒绝", "Allow": "允许", diff --git a/package.json b/package.json index 7ecc82866..5ea983e2a 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,23 @@ "ts-jest": "^29.0.0", "typesafe-actions": "^4.4.2", "typescript": "5.9.3", + "victory-area": "^37.3.6", + "victory-axis": "^37.3.6", + "victory-bar": "^37.3.6", + "victory-box-plot": "^37.3.6", + "victory-chart": "^37.3.6", + "victory-core": "^37.3.6", + "victory-create-container": "^37.3.6", + "victory-cursor-container": "^37.3.6", + "victory-group": "^37.3.6", + "victory-legend": "^37.3.6", + "victory-line": "^37.3.6", + "victory-pie": "^37.3.6", + "victory-scatter": "^37.3.6", + "victory-stack": "^37.3.6", + "victory-tooltip": "^37.3.6", + "victory-voronoi-container": "^37.3.6", + "victory-zoom-container": "^37.3.6", "webpack": "^5.1.0", "webpack-cli": "^4.9.2", "webpack-dev-server": "^4.9.3" diff --git a/plugin-metadata.ts b/plugin-metadata.ts index 5131a3327..f01261e9b 100644 --- a/plugin-metadata.ts +++ b/plugin-metadata.ts @@ -12,6 +12,7 @@ const metadata: ConsolePluginBuildMetadata = { "gitopsFlags": "./plugin/utils/flags", "topology": "./gitops/topology", ApplicationList: "./gitops/components/application/ApplicationListTab.tsx", + DashboardOverviewPage: "./gitops/components/overview/DashboardOverviewPage.tsx", ApplicationDetails: "./gitops/components/application/ApplicationNavPage.tsx", RolloutList: "./gitops/components/rollout/RolloutListTab.tsx", RolloutDetails: "./gitops/components/rollout/RolloutNavPage.tsx", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36484e5e2..fb33be9bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -85,7 +85,7 @@ importers: version: 6.4.0(@patternfly/react-core@6.4.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(marked@15.0.12)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) '@patternfly/react-charts': specifier: ^8.4.0 - version: 8.4.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + version: 8.4.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(victory-area@37.3.6(react@17.0.2))(victory-axis@37.3.6(react@17.0.2))(victory-bar@37.3.6(react@17.0.2))(victory-box-plot@37.3.6(react@17.0.2))(victory-chart@37.3.6(react@17.0.2))(victory-core@37.3.6(react@17.0.2))(victory-create-container@37.3.6(react@17.0.2))(victory-cursor-container@37.3.6(react@17.0.2))(victory-group@37.3.6(react@17.0.2))(victory-legend@37.3.6(react@17.0.2))(victory-line@37.3.6(react@17.0.2))(victory-pie@37.3.6(react@17.0.2))(victory-scatter@37.3.6(react@17.0.2))(victory-stack@37.3.6(react@17.0.2))(victory-tooltip@37.3.6(react@17.0.2))(victory-voronoi-container@37.3.6(react@17.0.2))(victory-zoom-container@37.3.6(react@17.0.2)) '@patternfly/react-component-groups': specifier: ^6.3.0 version: 6.3.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -260,6 +260,57 @@ importers: typescript: specifier: 5.9.3 version: 5.9.3 + victory-area: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-axis: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-bar: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-box-plot: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-chart: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-core: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-create-container: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-cursor-container: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-group: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-legend: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-line: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-pie: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-scatter: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-stack: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-tooltip: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-voronoi-container: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) + victory-zoom-container: + specifier: ^37.3.6 + version: 37.3.6(react@17.0.2) webpack: specifier: ^5.1.0 version: 5.105.2(webpack-cli@4.10.0) @@ -2660,9 +2711,15 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delaunator@4.0.1: + resolution: {integrity: sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==} + delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + delaunay-find@0.0.6: + resolution: {integrity: sha512-1+almjfrnR7ZamBk0q3Nhg6lqSe6Le4vL0WJDSMx4IDbQwTpUTXPjxC00lqLBT8MYsJpPCbI16sIkw9cPsbi7Q==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -3944,6 +4001,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -5588,6 +5648,135 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-area@37.3.6: + resolution: {integrity: sha512-wVC8LKrZJLiSySNuJLRCB449qZTsPiRyzLlNoJwe21y+XA/a2HJbmJSeywmo8P153aX8viKe1H8ygDsTFXQhHw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-axis@37.3.6: + resolution: {integrity: sha512-Vi0dZvgmXmnCdoqc49WckeG5cMXnl7FTtqVhXu9JweA9cgCnkZabBd5mRvAjblb3Lo4j0HZCSPKHYWUPW70qZg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-bar@37.3.6: + resolution: {integrity: sha512-jdATFRWL1LUW/yEpKWx/aId2BiU2o1pPF9+Kh1TFISBduJoI4ZqvZD90H1QK4f/z50PikqiqiDECaKoKM1jfOQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-box-plot@37.3.6: + resolution: {integrity: sha512-GOucnD63h14ScBuISC/nd1GBTEx6gIZfLE+0P0gyeH1poBKq0trTTvpQDvAMuGR8zICfEETG3ltmUMCwRrFyUg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-brush-container@37.3.6: + resolution: {integrity: sha512-LfZ2CgX1cYAqCtYxcSB68OfZS2v0T2VLXoEArd0lCXfRBY1Gya7GacCUcuo7GoK9XOXeslx7S/U95aVutt1VLg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-chart@37.3.6: + resolution: {integrity: sha512-IkPo/W4AJ7bPu902TGER09OseR9ODm+FQAKfOBw4JsdEhZZ7BiG9zgd/25+x0r5EsTLu81CYGQVkBa+ZazcOlA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-core@37.3.6: + resolution: {integrity: sha512-aFgO6KokxPbUCPznZP5UPhOdI22pMuwDXKDt6eoQOnkVim66Ia+K95TQar2nwVKGYV5j26aKVf/n9blwphGJRw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-create-container@37.3.6: + resolution: {integrity: sha512-Uf5bFQvqUsXCjqpvBW4LhrdrHkM6dBqxYgub6FCsBb86f84xZQ3vY7jFkg/JfvF0oGKMoWXYYrYLC1sk+fcWVA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-cursor-container@37.3.6: + resolution: {integrity: sha512-+Oiw57d5nE+iq8As8RvepknzmNtKq1Gsc50u1X3IRd4jXtX8zqZrgXGlVZ+BP/tkLsWnGYVjKulwKBf2oaEUuw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-group@37.3.6: + resolution: {integrity: sha512-kgy/Azl5BxwlJAV0KDPGypv35TMrOD1J2ZxnJW2Wyyq+e8i0GGBIv5MoBzou64BRsDlS9V0CYRIjnkHgrBpB5w==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-legend@37.3.6: + resolution: {integrity: sha512-vRRrhj3/ENqKVLdaBMzEmR83N6BOjox1bthYT1eJjN2H5SIK35bxn30IkiV/Pz3y627EqZe4TAWaxc0jiJlCiA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-line@37.3.6: + resolution: {integrity: sha512-Ke817uf/qFbN9jU7Dba7CrcHXYO5wAZuKKnyeHJmLDeQeFST0773xejnIuC+dBgZipjFr4KIbSd+VcUafFNE1g==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-pie@37.3.6: + resolution: {integrity: sha512-tvdgAZ/HQWlo3KDDe0XAVbizHuaNMbgkkiF7zfA7Ww+3bHSs+0P9dsDtK2xP365D8gBCOv8pWmuzvKRhzNbqeA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-polar-axis@37.3.6: + resolution: {integrity: sha512-RpFsCkzHezJq5P+C/wtVdjEHX25JIFsSgs6qYSnfr/hayaFbWgK5HhRFpriQm5hg61cx47WxAOLyHvzf0nasvw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-scatter@37.3.6: + resolution: {integrity: sha512-fp95zMTPXgW1cmTowzDXhn+KxePMVDrzU0lotsHQMdBV7eB+ioXdu9hORlx4VHmMYg2ihsGwRTF+VAZ7rGxphA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-selection-container@37.3.6: + resolution: {integrity: sha512-gd3qODDlBtLEJM7+2jCXk2YcLBUmIpYEEHswytMhwc6zihxXipGBUHRulhLj/I05mKay2gaOAg5ewiJHd4Awgw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-shared-events@37.3.6: + resolution: {integrity: sha512-ygrbOtzLUTbtKebacZKyQRekhSAROnAvMkVI/PKsAGsz0ClY9P7qDEJG7eTUUygjO6ax0tI6WNE6JogQzeD1gw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-stack@37.3.6: + resolution: {integrity: sha512-ldod04RdqGJGH5p5eWXCofdTkbhZqIp3iwW7NpxSbMDLs8zPQIVvDFVtuJgMwQiC5vnIpbhMmxVeFbr8m64ZKA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-tooltip@37.3.6: + resolution: {integrity: sha512-vqaJS9noauOqDDBBAV9Ln9duOY/i17h1DCfCPAqhwPFyvFbwKvAub9zPTeYWAm/14VvWX5O/0yekFCVbcC7hjg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + + victory-voronoi-container@37.3.6: + resolution: {integrity: sha512-qAAG0rMuK7A4EoJ4cyUk5wNdOW+HuCXNKPOko+hYK6wWOYXJvFhiglYyA85a695YyAXECc6JyJS/crm4IOEFag==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + + victory-zoom-container@37.3.6: + resolution: {integrity: sha512-AGL+k20mI44OL5b0VgIxlmnNSefIoFmbbim5NraPmIxbtns9qQW/56ivIncJcYomBungIx99gUpsEpcQaMNHgQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: '>=16.6.0' + vinyl-fs@3.0.3: resolution: {integrity: sha512-vIu34EkyNyJxmP0jscNzWBSygh7VWhqun6RmqVfXePrOwi9lhvRs//dOaGOTRUQr4tx7/zd26Tk5WeSVZitgng==} engines: {node: '>= 0.10'} @@ -7095,7 +7284,7 @@ snapshots: react: 17.0.2 react-dom: 17.0.2(react@17.0.2) - '@patternfly/react-charts@8.4.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2)': + '@patternfly/react-charts@8.4.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(victory-area@37.3.6(react@17.0.2))(victory-axis@37.3.6(react@17.0.2))(victory-bar@37.3.6(react@17.0.2))(victory-box-plot@37.3.6(react@17.0.2))(victory-chart@37.3.6(react@17.0.2))(victory-core@37.3.6(react@17.0.2))(victory-create-container@37.3.6(react@17.0.2))(victory-cursor-container@37.3.6(react@17.0.2))(victory-group@37.3.6(react@17.0.2))(victory-legend@37.3.6(react@17.0.2))(victory-line@37.3.6(react@17.0.2))(victory-pie@37.3.6(react@17.0.2))(victory-scatter@37.3.6(react@17.0.2))(victory-stack@37.3.6(react@17.0.2))(victory-tooltip@37.3.6(react@17.0.2))(victory-voronoi-container@37.3.6(react@17.0.2))(victory-zoom-container@37.3.6(react@17.0.2))': dependencies: '@patternfly/react-styles': 6.4.0 '@patternfly/react-tokens': 6.4.0 @@ -7104,6 +7293,24 @@ snapshots: react: 17.0.2 react-dom: 17.0.2(react@17.0.2) tslib: 2.8.1 + optionalDependencies: + victory-area: 37.3.6(react@17.0.2) + victory-axis: 37.3.6(react@17.0.2) + victory-bar: 37.3.6(react@17.0.2) + victory-box-plot: 37.3.6(react@17.0.2) + victory-chart: 37.3.6(react@17.0.2) + victory-core: 37.3.6(react@17.0.2) + victory-create-container: 37.3.6(react@17.0.2) + victory-cursor-container: 37.3.6(react@17.0.2) + victory-group: 37.3.6(react@17.0.2) + victory-legend: 37.3.6(react@17.0.2) + victory-line: 37.3.6(react@17.0.2) + victory-pie: 37.3.6(react@17.0.2) + victory-scatter: 37.3.6(react@17.0.2) + victory-stack: 37.3.6(react@17.0.2) + victory-tooltip: 37.3.6(react@17.0.2) + victory-voronoi-container: 37.3.6(react@17.0.2) + victory-zoom-container: 37.3.6(react@17.0.2) '@patternfly/react-component-groups@6.3.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2)': dependencies: @@ -8748,10 +8955,16 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delaunator@4.0.1: {} + delaunator@5.0.1: dependencies: robust-predicates: 3.0.2 + delaunay-find@0.0.6: + dependencies: + delaunator: 4.0.1 + delayed-stream@1.0.0: {} depd@1.1.2: {} @@ -10460,6 +10673,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-safe@5.0.1: {} + json5@1.0.2: dependencies: minimist: 1.2.8 @@ -12190,6 +12405,174 @@ snapshots: vary@1.1.2: {} + victory-area@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + victory-vendor: 37.3.6 + + victory-axis@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + + victory-bar@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + victory-vendor: 37.3.6 + + victory-box-plot@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + victory-vendor: 37.3.6 + + victory-brush-container@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + react-fast-compare: 3.2.2 + victory-core: 37.3.6(react@17.0.2) + + victory-chart@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + react-fast-compare: 3.2.2 + victory-axis: 37.3.6(react@17.0.2) + victory-core: 37.3.6(react@17.0.2) + victory-polar-axis: 37.3.6(react@17.0.2) + victory-shared-events: 37.3.6(react@17.0.2) + + victory-core@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + react-fast-compare: 3.2.2 + victory-vendor: 37.3.6 + + victory-create-container@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-brush-container: 37.3.6(react@17.0.2) + victory-core: 37.3.6(react@17.0.2) + victory-cursor-container: 37.3.6(react@17.0.2) + victory-selection-container: 37.3.6(react@17.0.2) + victory-voronoi-container: 37.3.6(react@17.0.2) + victory-zoom-container: 37.3.6(react@17.0.2) + + victory-cursor-container@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + + victory-group@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + react-fast-compare: 3.2.2 + victory-core: 37.3.6(react@17.0.2) + victory-shared-events: 37.3.6(react@17.0.2) + + victory-legend@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + + victory-line@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + victory-vendor: 37.3.6 + + victory-pie@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + victory-vendor: 37.3.6 + + victory-polar-axis@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + + victory-scatter@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + + victory-selection-container@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + + victory-shared-events@37.3.6(react@17.0.2): + dependencies: + json-stringify-safe: 5.0.1 + lodash: 4.18.1 + react: 17.0.2 + react-fast-compare: 3.2.2 + victory-core: 37.3.6(react@17.0.2) + + victory-stack@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + react-fast-compare: 3.2.2 + victory-core: 37.3.6(react@17.0.2) + victory-shared-events: 37.3.6(react@17.0.2) + + victory-tooltip@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + + victory-voronoi-container@37.3.6(react@17.0.2): + dependencies: + delaunay-find: 0.0.6 + lodash: 4.18.1 + react: 17.0.2 + react-fast-compare: 3.2.2 + victory-core: 37.3.6(react@17.0.2) + victory-tooltip: 37.3.6(react@17.0.2) + + victory-zoom-container@37.3.6(react@17.0.2): + dependencies: + lodash: 4.18.1 + react: 17.0.2 + victory-core: 37.3.6(react@17.0.2) + vinyl-fs@3.0.3: dependencies: fs-mkdirp-stream: 1.0.0 diff --git a/src/gitops/components/overview/DashboardOverviewPage.scss b/src/gitops/components/overview/DashboardOverviewPage.scss new file mode 100644 index 000000000..a6dd1f014 --- /dev/null +++ b/src/gitops/components/overview/DashboardOverviewPage.scss @@ -0,0 +1,64 @@ +// Overview dashboard cards in grid layout +.gitops-dashboard__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: var(--pf-t--global--spacer--gutter--default); +} + +.gitops-dashboard__chart-card { + min-width: 300px; +} + +// Fix the donut to set size so it doesn't grow as the card grows. +.gitops-dashboard__donut-container { + height: 270px; + width: 270px; + overflow: hidden; + flex-shrink: 0; +} + +.gitops-dashboard__donut-card-body { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.gitops-dashboard__fixed-height-row-card { + @media (min-width: 768px) { + height: 350px; + } +} +.gitops-dashboard__chart-container { + @media (min-width: 768px) { + width: 100%; + } +} + +.gitops-dashboard__reconcile-card-body { + height: 100%; +} + +.gitops-dashboard__reconcile-card { + @media (min-width: 768px) { + height: 310px; + } +} + +.gitops-dashboard__half-size-empty-state { + --pf-v6-c-empty-state__title-text--FontSize: 0.8rem; + --pf-v6-c-empty-state--PaddingBlockEnd: 4rem; + .pf-v6-c-spinner { + --pf-v6-c-spinner--diameter: 1.75rem; + } +} + +.gitops-dashboard__label-with-external-link { + padding-inline-start: 10px; + padding-inline-end: 10px; + min-width: 40px; +} + +.gitops-dashboard__link { + --pf-t--global--text-decoration--style--200: dashed; +} diff --git a/src/gitops/components/overview/DashboardOverviewPage.tsx b/src/gitops/components/overview/DashboardOverviewPage.tsx new file mode 100644 index 000000000..4928caa8e --- /dev/null +++ b/src/gitops/components/overview/DashboardOverviewPage.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { Navigate, useNavigate, useParams } from 'react-router-dom-v5-compat'; + +import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { + ListPageBody, + ListPageHeader, + NamespaceBar, + useActiveNamespace, +} from '@openshift-console/dynamic-plugin-sdk'; +import { Grid, GridItem, PageSection } from '@patternfly/react-core'; + +import HealthStatusCard from './health-status/HealthStatusCard'; +import OperationalMetricsCard from './operational-metrics/OperationalMetricsCard'; +import ReconciliationCard from './reconciliation/ReconciliationCard'; +import SyncActivityCard from './sync-activity/SyncActivityCard'; +import SyncStatusCard from './sync-status/SyncStatusCard'; + +import './DashboardOverviewPage.scss'; + +const ALL_NAMESPACES_KEY = '#ALL_NS#'; + +const buildOverviewPath = (namespace: string): string => + !namespace || namespace === ALL_NAMESPACES_KEY + ? '/gitops/overview/all-namespaces' + : `/gitops/overview/ns/${namespace}`; + +// Registered at the `/gitops/overview` path - see console-extensions.json +export const GitOpsOverviewRedirect: React.FC = () => { + const [activeNamespace] = useActiveNamespace(); + return ; +}; + +interface DashboardOverviewPageProps { + namespace?: string; +} + +const DashboardOverviewPage: React.FC = ({ + namespace: namespaceProp, +}) => { + const { ns } = useParams<{ ns?: string }>(); + const navigate = useNavigate(); + const [, setActiveNamespace] = useActiveNamespace(); + const namespace = ns || namespaceProp; + + React.useEffect(() => { + setActiveNamespace(namespace || ALL_NAMESPACES_KEY); + }, [namespace, setActiveNamespace]); + + const handleNamespaceChange = (newNamespace: string) => { + navigate(buildOverviewPath(newNamespace)); + }; + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ ); +}; + +export default DashboardOverviewPage; diff --git a/src/gitops/components/overview/chartHelpers.test.tsx b/src/gitops/components/overview/chartHelpers.test.tsx new file mode 100644 index 000000000..9b9f7ee52 --- /dev/null +++ b/src/gitops/components/overview/chartHelpers.test.tsx @@ -0,0 +1,210 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { MemoryRouter } from 'react-router-dom-v5-compat'; + +import { + countTickFormat, + errorState, + getCountAxisConfig, + getDayOfWeek, + getNamespacePath, + LinkedTSpan, + loadingState, + NoDataPointsFound, + relativeTimeTickFormat, +} from './chartHelpers'; + +describe('relativeTimeTickFormat', () => { + // `relativeTimeTickFormat` computes "time ago" labels relative to `Date.now()`, so pin it to a + // fixed instant for deterministic assertions. + const NOW = new Date('2024-01-01T12:00:00.000Z').getTime(); + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('formats sub-hour tick intervals as minutes-ago', () => { + const ticks = [NOW - 10 * 60 * 1000, NOW - 5 * 60 * 1000, NOW]; + expect(relativeTimeTickFormat(NOW - 5 * 60 * 1000, 1, ticks)).toBe('-5m'); + }); + + it('formats sub-hour (but not sub-minute) intervals as -h:mm', () => { + // Interval between ticks is 20 minutes (< 1h), but the tick itself is 90 minutes in the past, + // which is NOT "less than an hour away" overall -- hits the `-${mod}:${rem}` branch. + const ticks = [NOW - 40 * MINUTE_MS(), NOW - 20 * MINUTE_MS(), NOW]; + expect(relativeTimeTickFormat(NOW - 90 * MINUTE_MS(), 1, ticks)).toBe('-1:30'); + }); + + it('formats hour-or-longer tick intervals using a localized clock time', () => { + const oneHourAgo = NOW - 60 * 60 * 1000; + const ticks = [oneHourAgo, NOW]; + const t = new Date(oneHourAgo); + const datePart = + getDayOfWeek(t.getDay()) + + ' ' + + t.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); + expect(relativeTimeTickFormat(oneHourAgo, 0, ticks)).toBe( + datePart + '\n' + t.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }), + ); + }); + + it('falls back to a 1-hour assumed interval when fewer than two ticks are given', () => { + const t = new Date(NOW - 60 * 60 * 1000); + const datePart = + getDayOfWeek(t.getDay()) + + ' ' + + t.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); + expect(relativeTimeTickFormat(NOW - 60 * 60 * 1000, 0, [NOW - 60 * 60 * 1000])).toBe( + datePart + '\n' + t.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }), + ); + }); +}); + +function MINUTE_MS() { + return 60 * 1000; +} + +describe('countTickFormat', () => { + it('formats small counts verbatim', () => { + expect(countTickFormat(0)).toBe('0'); + expect(countTickFormat(7)).toBe('7'); + }); + + it('truncates fractional ticks to whole numbers', () => { + expect(countTickFormat(4.8)).toBe('4'); + }); + + it('compact-formats large counts', () => { + expect(countTickFormat(1500)).toBe('1.5K'); + expect(countTickFormat(2_000_000)).toBe('2M'); + }); +}); + +describe('getCountAxisConfig', () => { + it('reserves headroom above the data max and lands the top tick exactly on it', () => { + const { tickValues, domainMax } = getCountAxisConfig(8, 1.2, 5); + + expect(domainMax).toBe(tickValues[tickValues.length - 1]); + expect(domainMax).toBeGreaterThanOrEqual(8 * 1.2); + }); + + it('produces evenly-spaced, whole-number ticks (no duplicate labels after truncation)', () => { + const { tickValues } = getCountAxisConfig(7, 1.2, 5); + + for (let i = 1; i < tickValues.length; i += 1) { + expect(Number.isInteger(tickValues[i])).toBe(true); + expect(tickValues[i] - tickValues[i - 1]).toBe(tickValues[1] - tickValues[0]); + } + // Distinct after Number.parseInt-style truncation -- the bug this guards against. + const truncated = tickValues.map((v) => Math.trunc(v)); + expect(new Set(truncated).size).toBe(truncated.length); + }); + + it('never produces a sub-1 step, even for a tiny data max', () => { + const { tickValues } = getCountAxisConfig(1, 1.2, 5); + const step = tickValues[1] - tickValues[0]; + + expect(step).toBeGreaterThanOrEqual(1); + }); + + it('treats an all-zero data max the same as a max of 1 (still produces whole-number ticks)', () => { + const { tickValues, domainMax } = getCountAxisConfig(0); + + expect(domainMax).toBeGreaterThan(0); + expect(tickValues[0]).toBe(0); + }); + + it('picks a "nice" step from the 1/2/5 x 10^n family for larger domains', () => { + const { tickValues } = getCountAxisConfig(950, 1.2, 5); + const step = tickValues[1] - tickValues[0]; + const power = Math.floor(Math.log10(step)); + const normalized = step / Math.pow(10, power); + + expect([1, 2, 5, 10]).toContain(Math.round(normalized)); + }); +}); + +describe('getNamespacePath', () => { + it('returns the all-namespaces path when no namespace is given', () => { + expect(getNamespacePath(undefined)).toBe('/all-namespaces'); + expect(getNamespacePath('')).toBe('/all-namespaces'); + }); + + it('returns the namespaced path otherwise', () => { + expect(getNamespacePath('openshift-gitops')).toBe('/ns/openshift-gitops'); + }); +}); + +describe('NoDataPointsFound', () => { + it('renders the empty-state message at the requested height', () => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain('No datapoints found.'); + expect(markup).toContain('height:190px'); + }); +}); + +describe('LinkedTSpan', () => { + it('wraps the matching linkText tspan in a Link to the given destination', () => { + // `Link`'s click handler calls `useNavigate`, which sets up a `useLayoutEffect` -- harmless, + // but React logs a "useLayoutEffect does nothing on the server" warning under + // `renderToStaticMarkup`. Silence just that expected noise for this one test. + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + const markup = renderToStaticMarkup( + + + + + Synced + + + + , + ); + + consoleError.mockRestore(); + + expect(markup).toContain(''); + expect(markup).toContain('>Synced'); + }); + + it('renders non-matching tspan text as plain text, with no link', () => { + const markup = renderToStaticMarkup( + + + + : 3 (60%) + + + , + ); + + expect(markup).not.toContain(': 3 (60%)'); + }); +}); + +describe('loadingState', () => { + it('renders an EmptyState using a Spinner icon', () => { + const markup = renderToStaticMarkup(loadingState()); + + expect(markup).toContain('class="gitops-dashboard__half-size-empty-state"'); + expect(markup).toContain('data-status="custom"'); + expect(markup).toContain('role="progressbar"'); + expect(markup).toContain('Loading...'); + }); +}); + +describe('errorState', () => { + it('builds an ErrorState element with the given title and body text', () => { + const element = errorState('Unable to retrieve applications', 'boom'); + + expect(element.props.titleText).toBe('Unable to retrieve applications'); + expect(element.props.bodyText).toBe('boom'); + expect(element.props.status).toBe('danger'); + }); +}); diff --git a/src/gitops/components/overview/chartHelpers.tsx b/src/gitops/components/overview/chartHelpers.tsx new file mode 100644 index 000000000..64fa2d7bb --- /dev/null +++ b/src/gitops/components/overview/chartHelpers.tsx @@ -0,0 +1,308 @@ +import * as React from 'react'; +import { Link } from 'react-router-dom-v5-compat'; +import { TSpan } from 'victory-core'; + +import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { ChartLabel, ChartLegendTooltip } from '@patternfly/react-charts/victory'; +import { ErrorState } from '@patternfly/react-component-groups'; +import { EmptyState, Spinner } from '@patternfly/react-core'; + +// Shared chart utilities + +// Renders the Chart Legend Tooltip content plus a vertical crosshair line at the +// hovered x position, which mirrors Console's own metrics charts, e.g. `query-browser__tooltip-line` +// in `QueryBrowser.tsx`). Keep it consistent. +export const ChartCursorLine: React.FC = (props) => { + const { x, height, scale } = props; + // `height` is the full canvas size, which includes padding reserved for axis tick labels/legend + // `scale.y` is the fully-resolved d3 scale Victory computed for this chart (domain -> pixel) + const yRange: [number, number] | undefined = scale?.y?.range?.(); + const y1 = yRange ? Math.min(yRange[0], yRange[1]) : 0; + const y2 = yRange ? Math.max(yRange[0], yRange[1]) : height; + return ( + <> + + + + ); +}; + +// Renders the axis tick label produced by either x-axis's `tickFormat` +export const RelativeTimeTickLabel = ({ text, ...props }: any) => ( + +); + +const HOUR_MS = 60 * 60 * 1000; +const MINUTE_MS = 60 * 1000; + +// Shared by both charts' dependent (Y) axes. +export const Y_AXIS_STYLE = { + tickLabels: { + fontSize: '11px', + fill: 'var(--pf-t--global--text--color--subtle)', + }, +}; + +export const relativeTimeTickFormat = (time: any, _index: number, ticks: number[]): string => { + const dateTickTime = new Date(time); + const tickTime = dateTickTime.getTime(); + const intervalMs = + ticks.length > 1 ? new Date(ticks[1]).getTime() - new Date(ticks[0]).getTime() : HOUR_MS; + // For tick intervals of less than 1 hour, we should use converted minutes. + const isIntervalLessThanHour = Math.abs(ticks.length * intervalMs) < HOUR_MS; + if (isIntervalLessThanHour) { + return `${Math.round((tickTime - Date.now()) / MINUTE_MS)}m`; + } + if (intervalMs < HOUR_MS) { + const inMin = Math.abs(Math.round((tickTime - Date.now()) / MINUTE_MS)); + const rem = inMin % 60; + const mod = inMin >= 60 ? Math.floor(inMin / 60) : 0; + return `-${mod}:${String(rem).padStart(2, '0')}`; + } + const datePart = + getDayOfWeek(dateTickTime.getDay()) + + ' ' + + dateTickTime.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); + return ( + datePart + + '\n' + + dateTickTime.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) + ); +}; + +export const getDayOfWeek = (day: number): string => { + switch (day) { + case 0: + return 'Sun'; + case 1: + return 'Mon'; + case 2: + return 'Tue'; + case 3: + return 'Wed'; + case 4: + return 'Thu'; + case 5: + return 'Fri'; + case 6: + return 'Sat'; + } +}; + +// Shared by time series charts (eg. Sync Activity and Reconciliation) to format thedependent (Y) axes. +const numberFormatter = new Intl.NumberFormat('en-US', { + notation: 'compact', + compactDisplay: 'short', +}); + +export const countTickFormat = (time: any): string => + numberFormatter.format(Number.parseInt(time, 10)); + +// Time series charts' dependent (Y) axes plot discrete counts and NOT fractional tick values. +// Rounding instead of truncating will not fix this. (0.2/0.4 -> 0, 0.6/0.8 -> 1 still collides) -- +// The fix has to happen when generating the ticks and not when formatting the ticks. +//// +// This version picks a genuinely "nice" step instead -- from the same 1/2/5 x 10^n family d3's own +// `tickIncrement` picks from for larger domains, just floored to a minimum of 1 since these axes only +// ever plot whole numbers. +const NICE_STEP_SQRT_2 = Math.sqrt(2); // ~1.414 -- d3's threshold between a "1" and "2" step. +const NICE_STEP_SQRT_10 = Math.sqrt(10); // ~3.162 -- d3's threshold between a "2" and "5" step. +const NICE_STEP_SQRT_50 = Math.sqrt(50); // ~7.071 -- d3's threshold between a "5" and "10" step. + +const getNiceIntegerStep = (rawStep: number): number => { + const clamped = Math.max(rawStep, 1); + const power = Math.floor(Math.log10(clamped)); + const base = Math.pow(10, power); + const error = clamped / base; + + const multiple = + /* eslint-disable-next-line no-nested-ternary */ + error >= NICE_STEP_SQRT_50 + ? 10 /* eslint-disable-next-line no-nested-ternary */ + : error >= NICE_STEP_SQRT_10 + ? 5 + : error >= NICE_STEP_SQRT_2 + ? 2 + : 1; + return Math.max(1, Math.round(multiple * base)); +}; + +export type CountAxisConfig = { tickValues: number[]; domainMax: number }; + +export const getCountAxisConfig = (maxY: number, headroom = 1.2, maxTicks = 5): CountAxisConfig => { + const target = Math.max(maxY, 1) * headroom; + const step = getNiceIntegerStep(target / maxTicks); + const domainMax = step * Math.ceil(target / step); + const tickValues: number[] = []; + for (let v = 0; v <= domainMax; v += step) { + tickValues.push(v); + } + return { tickValues, domainMax }; +}; + +// Similar to Console's metric chart empty state (`GraphEmpty` in `graph-empty.tsx`, used by `QueryBrowser.tsx`) +// Shown when a Prometheus query returns no data point. +export const NoDataPointsFound: React.FC<{ height: number }> = ({ height }) => ( +
+
{t('No datapoints found.')}
+
+); + +// Assisted by AI agent so our charts will behave like Console's own metrics chart. See comments below +// for AI agent's generated documenation. It works well. +// +// Tracks the actual rendered pixel width of a time series chart container div (via the returned callback +// ref), so it can be fed back into that chart's own `` prop. This makes +// Victory recompute its real layout (axis positions, tick spacing, line points) for the +// container's exact current width, so text renders at its native, undistorted size +// +// This mirrors Console's own metrics chart, `QueryBrowser.tsx`'s `useRefWidth` hook (also just a +// ref + measured width fed into ``) -- `ResizeObserver` is used here instead +// of `useRefWidth`'s `window.resize`/`sidebar_toggle` event listeners since it directly observes +// this specific element instead of assuming what else might resize it. +// +// The returned ref *must* be a callback ref, not a plain `useRef` + a `useEffect(..., [])`: the +// chart div this gets attached to only renders once its own `promLoaded`/`promError` (from the +// async `usePrometheusPoll` call) resolves, which happens well after the component's first +// render. A `[]`-deps effect only runs once, immediately after that *first* render -- at which +// point the div doesn't exist yet, so `.current` would be `null` and the observer would never +// actually get created, leaving the width stuck at its initial default forever. A callback ref +// instead fires exactly when React actually attaches (or detaches) the DOM node, whenever that +// happens to be. +// +// Each chart calls this separately (its own `useState`/`useRef`/callback, not shared) rather than +// both charts sharing one `ResizeObserver` instance: a shared instance only ever tracks whichever +// div's ref callback fired *most* recently, silently disconnecting from the other -- invisible +// today only because both charts' `GridItem`s happen to use identical breakpoints (so they're +// always the same width regardless of which one is actually being observed), but fragile if that +// ever changes. +export function useChartContainerWidth( + defaultWidth: number, +): [number, (el: HTMLDivElement | null) => void] { + const [width, setWidth] = React.useState(defaultWidth); + const resizeObserverRef = React.useRef(null); + const containerRef = React.useCallback((el: HTMLDivElement | null) => { + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = null; + if (!el) { + return; + } + setWidth(el.getBoundingClientRect().width); + const resizeObserver = new ResizeObserver(([entry]) => { + if (entry) { + setWidth(entry.contentRect.width); + } + }); + resizeObserver.observe(el); + resizeObserverRef.current = resizeObserver; + }, []); + return [width, containerRef]; +} + +// All of our cards will accept a namespace since the project can be specific or All Projects. +export interface OverviewCardProps { + namespace?: string; +} + +// For legendData property of a ChartDonut +export interface LegendData { + name: string; + symbol: { type: string }; + url: string; + // link: string; + linkText: string; + suffixText: string; +} + +// For data property of a ChartDonut +export interface ChartData { + x?: string; + y?: number; +} + +// Renders a `ChartLegend` label's text as a link to the matching filtered Application list +// linkText that matches will have the link. Other text will just be regular text. +export const LinkedTSpan = ({ linkText, to, children, ...props }: any) => { + const tspan = {children}; + return children === linkText ? ( + e.stopPropagation()} className="gitops-dashboard__link"> + {tspan} + + ) : ( + tspan + ); +}; + +export const loadingState = () => { + return ( + + ); +}; + +export const errorState = (errorTitle: string, errorMessage: string) => { + return ( + } // Hide the button + bodyText={errorMessage} + /> + ); +}; + +export const StatusChartLegendLabel = ({ datum, ...props }: any) => { + return ( + } + style={[ + { + ...props.style, + fontSize: 12, + cursor: 'pointer', + textDecoration: 'underline', + textDecorationStyle: 'dashed', + fill: 'var(--pf-t--global--text--color--link--default)', + height: 12, + }, + { + ...props.style, + fontSize: 12, + height: 12, + }, + ]} + /> + ); +}; + +export const getNamespacePath = (namespace: string): string => + !namespace ? '/all-namespaces' : `/ns/${namespace}`; + +export const DEFAULT_GITOPS_OPERATOR_NAMESPACE = 'openshift-gitops-operator'; diff --git a/src/gitops/components/overview/health-status/HealthStatusCard.tsx b/src/gitops/components/overview/health-status/HealthStatusCard.tsx new file mode 100644 index 000000000..78e25f90f --- /dev/null +++ b/src/gitops/components/overview/health-status/HealthStatusCard.tsx @@ -0,0 +1,222 @@ +import * as React from 'react'; + +import { ApplicationKind } from '@gitops/models/ApplicationModel'; +import { HealthStatus } from '@gitops/utils/constants'; +import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { K8sResourceCommon, useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { ChartDonut, ChartLegend, ChartTooltip } from '@patternfly/react-charts/victory'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; + +import { + ChartData, + errorState, + getNamespacePath, + LegendData, + loadingState, + OverviewCardProps, + StatusChartLegendLabel, +} from '../chartHelpers'; + +// Shared dashboard-wide styles across all cards +import '../DashboardOverviewPage.scss'; + +// Self-contained "Health status" dashboard card, that watches Applications +const HealthStatusCard: React.FC = ({ namespace }) => { + const [applications, loaded, loadError] = useK8sWatchResource({ + isList: true, + groupVersionKind: { + group: 'argoproj.io', + kind: 'Application', + version: 'v1alpha1', + }, + namespaced: !!namespace, + namespace, + }); + + const namespacePath = getNamespacePath(namespace); + const appsCount = applications.length ? applications.length : 0; + const healthStatus = applications.map( + (application: ApplicationKind) => application.status?.health?.status, + ); + + const healthStatusCount = healthStatus.reduce((acc, status) => { + switch (status) { + case HealthStatus.HEALTHY: + acc[HealthStatus.HEALTHY] = (acc[HealthStatus.HEALTHY] || 0) + 1; + break; + case HealthStatus.PROGRESSING: + acc[HealthStatus.PROGRESSING] = (acc[HealthStatus.PROGRESSING] || 0) + 1; + break; + case HealthStatus.DEGRADED: + acc[HealthStatus.DEGRADED] = (acc[HealthStatus.DEGRADED] || 0) + 1; + break; + case HealthStatus.MISSING: + acc[HealthStatus.MISSING] = (acc[HealthStatus.MISSING] || 0) + 1; + break; + case HealthStatus.SUSPENDED: + acc[HealthStatus.SUSPENDED] = (acc[HealthStatus.SUSPENDED] || 0) + 1; + break; + case HealthStatus.UNKNOWN: + acc[HealthStatus.UNKNOWN] = (acc[HealthStatus.UNKNOWN] || 0) + 1; + break; + default: + acc[HealthStatus.UNKNOWN] = (acc[HealthStatus.UNKNOWN] || 0) + 1; + break; + } + return acc; + }, {}); + + const actualHealthyCount = healthStatusCount[HealthStatus.HEALTHY] || 0; + const actualProgressingCount = healthStatusCount[HealthStatus.PROGRESSING] || 0; + const actualDegradedCount = healthStatusCount[HealthStatus.DEGRADED] || 0; + const actualMissingCount = healthStatusCount[HealthStatus.MISSING] || 0; + const actualSuspendedCount = healthStatusCount[HealthStatus.SUSPENDED] || 0; + const actualUnknownCount = healthStatusCount[HealthStatus.UNKNOWN] || 0; + + const healthStatusChartData: ChartData[] = [ + { x: HealthStatus.HEALTHY, y: actualHealthyCount }, + { x: HealthStatus.PROGRESSING, y: actualProgressingCount }, + { x: HealthStatus.DEGRADED, y: actualDegradedCount }, + { x: HealthStatus.MISSING, y: actualMissingCount }, + { x: HealthStatus.SUSPENDED, y: actualSuspendedCount }, + { x: HealthStatus.UNKNOWN, y: actualUnknownCount }, + ]; + + const healthyPercentage = + appsCount === 0 || actualHealthyCount == 0 ? 0 : (actualHealthyCount / appsCount) * 100; + const progressingPercentage = + appsCount === 0 || actualProgressingCount == 0 ? 0 : (actualProgressingCount / appsCount) * 100; + const degradedPercentage = + appsCount === 0 || actualDegradedCount == 0 ? 0 : (actualDegradedCount / appsCount) * 100; + const missingPercentage = + appsCount === 0 || actualMissingCount == 0 ? 0 : (actualMissingCount / appsCount) * 100; + const suspendedPercentage = + appsCount === 0 || actualSuspendedCount == 0 ? 0 : (actualSuspendedCount / appsCount) * 100; + const unknownPercentage = + appsCount === 0 || actualUnknownCount == 0 ? 0 : (actualUnknownCount / appsCount) * 100; + + const healthStatusLegendData: LegendData[] = [ + { + name: `Healthy: ${actualHealthyCount} (${healthyPercentage.toFixed(0)}%)`, + symbol: { type: 'circle' }, + url: + '/k8s/' + namespacePath + '/argoproj.io~v1alpha1~Application?rowFilter-app-health=Healthy', + linkText: t('Healthy'), + suffixText: `: ${actualHealthyCount} (${healthyPercentage.toFixed(0)}%)`, + }, + { + name: `Progressing: ${actualProgressingCount} (${progressingPercentage.toFixed(0)}%)`, + symbol: { type: 'circle' }, + url: + '/k8s/' + + namespacePath + + '/argoproj.io~v1alpha1~Application?rowFilter-app-health=Progressing', + linkText: t('Progressing'), + suffixText: `: ${actualProgressingCount} (${progressingPercentage.toFixed(0)}%)`, + }, + { + name: `Degraded: ${actualDegradedCount} (${degradedPercentage.toFixed(0)}%)`, + symbol: { type: 'circle' }, + url: + '/k8s/' + namespacePath + '/argoproj.io~v1alpha1~Application?rowFilter-app-health=Degraded', + linkText: t('Degraded'), + suffixText: `: ${actualDegradedCount} (${degradedPercentage.toFixed(0)}%)`, + }, + { + name: `Missing: ${actualMissingCount} (${missingPercentage.toFixed(0)}%)`, + symbol: { type: 'circle' }, + url: + '/k8s/' + namespacePath + '/argoproj.io~v1alpha1~Application?rowFilter-app-health=Missing', + linkText: t('Missing'), + suffixText: `: ${actualMissingCount} (${missingPercentage.toFixed(0)}%)`, + }, + { + name: `Suspended: ${actualSuspendedCount} (${suspendedPercentage.toFixed(0)}%)`, + symbol: { type: 'circle' }, + url: + '/k8s/' + + namespacePath + + '/argoproj.io~v1alpha1~Application?rowFilter-app-health=Suspended', + linkText: t('Suspended'), + suffixText: `: ${actualSuspendedCount} (${suspendedPercentage.toFixed(0)}%)`, + }, + { + name: `Unknown: ${actualUnknownCount} (${unknownPercentage.toFixed(0)}%)`, + symbol: { type: 'circle' }, + url: + '/k8s/' + namespacePath + '/argoproj.io~v1alpha1~Application?rowFilter-app-health=Unknown', + linkText: t('Unknown'), + suffixText: `: ${actualUnknownCount} (${unknownPercentage.toFixed(0)}%)`, + }, + ]; + + return ( + + {t('Health status')} + + {/* eslint-disable-next-line no-nested-ternary */} + {!loaded && !loadError ? ( +
{loadingState()}
+ ) : loadError ? ( +
+ {errorState( + t('Unable to retrieve applications'), + t('Error retrieving applications: ', loadError), + )} +
+ ) : ( +
+ `${datum.x}: ${datum.y}`} + legendData={healthStatusLegendData} + legendPosition="bottom" + legendOrientation="vertical" + colorScale={[ + 'var(--pf-t--global--color--status--success--default)', + 'var(--pf-t--global--color--nonstatus--blue--default)', + 'var(--pf-t--global--color--status--danger--default)', + 'var(--pf-t--global--color--status--warning--default)', + 'var(--pf-t--global--color--nonstatus--purple--default)', + 'rgb(204, 214, 221)', + ]} + name="HealthStatus" + padding={{ + bottom: 150, + left: 10, + right: 10, + top: 20, + }} + labelComponent={ + + } + legendComponent={ + } + style={{ labels: { fontSize: 12 } }} + symbolSpacer={8} + rowGutter={0} + /> + } + title={appsCount.toString()} + subTitle={t('Apps')} + /> +
+ )} +
+
+ ); +}; + +export default HealthStatusCard; diff --git a/src/gitops/components/overview/operational-metrics/OperationalMetricsCard.tsx b/src/gitops/components/overview/operational-metrics/OperationalMetricsCard.tsx new file mode 100644 index 000000000..80f79cab4 --- /dev/null +++ b/src/gitops/components/overview/operational-metrics/OperationalMetricsCard.tsx @@ -0,0 +1,335 @@ +import * as React from 'react'; + +import ExternalLink from '@gitops/utils/components/ExternalLink/ExternalLink'; +import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { PrometheusEndpoint, usePrometheusPoll } from '@openshift-console/dynamic-plugin-sdk'; +import { + Card, + CardBody, + CardTitle, + Grid, + GridItem, + Label, + Spinner, + Tooltip, +} from '@patternfly/react-core'; + +import { OverviewCardProps } from '../chartHelpers'; + +import '../DashboardOverviewPage.scss'; + +const rowMetric = ( + title: string, + query: string, + loading: boolean, + error: any, + tooltipContent: string, + labelComponent: React.ReactElement, +) => { + return ( + + + +
{title}
+
+
+ + + {/* eslint-disable-next-line no-nested-ternary */} + {!loading && !error ? ( + + + + ) : error ? ( + + + + ) : ( + {labelComponent} + )} + + +
+ ); +}; + +const OperationalMetricsCard: React.FC = ({ namespace }) => { + const lastUpdateTime = 10; // in seconds + + // Successful Syncs + const successfulSyncsPQ = + `sum(argocd_app_sync_total{phase='Succeeded'` + + (namespace ? `,namespace='` + namespace + `'` : ``) + + `})`; + const [resultSuccessfulSyncsPQ, loadedSuccessfulSyncsPQ, errorSuccessfulSyncsPQ] = + usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY, + delay: lastUpdateTime * 1000, + query: successfulSyncsPQ, + }); + const noOfSuccessfulSyncs = resultSuccessfulSyncsPQ?.data?.result[0]?.value[1]; + // Use first metric to determine last update time. For the other metrics, it will be the same delay + const timeOfSuccessfulSyncs = resultSuccessfulSyncsPQ?.data?.result[0]?.value[0] + ? new Date( + Number(resultSuccessfulSyncsPQ?.data?.result[0]?.value[0]) * 1000, + ).toLocaleTimeString() + : undefined; + + // Failed Syncs + const failedSyncsPQ = + `sum(argocd_app_sync_total{phase!='Succeeded'` + + (namespace ? `,namespace='` + namespace + `'` : ``) + + `})`; + const [resultFailedSyncsPQ, loadedFailedSyncsPQ, errorFailedSyncsPQ] = usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY, + delay: lastUpdateTime * 1000, + query: failedSyncsPQ, + }); + const noOfFailedSyncs = resultFailedSyncsPQ?.data?.result[0]?.value[1]; + + // Orphaned Resources + const orphanedResourcesCountPQ = + `sum(argocd_app_orphaned_resources_count` + + (namespace ? `{namespace='` + namespace + `'}` : ``) + + `)`; + const [ + resultOrphanedResourcesCountPQ, + loadedOrphanedResourcesCountPQ, + errorOrphanedResourcesCountPQ, + ] = usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY, + delay: lastUpdateTime * 1000, + query: orphanedResourcesCountPQ, + }); + const noOfOrphanedResources = resultOrphanedResourcesCountPQ?.data?.result[0]?.value[1]; + + // Apps with Autosync enabled + const autosyncEnabledPQ = + `sum(argocd_app_info{autosync_enabled='true'` + + (namespace ? `,namespace='` + namespace + `'` : ``) + + `})`; + const [resultAutosyncEnabledPQ, loadedAutosyncEnabledPQ, errorAutosyncEnabledPQ] = + usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY, + delay: lastUpdateTime * 1000, + query: autosyncEnabledPQ, + }); + const noOfAutosyncEnabled = resultAutosyncEnabledPQ?.data?.result[0]?.value[1]; + + // Apps with Autosync disabled + const autosyncDisabledPQ = + `sum(argocd_app_info{autosync_enabled='false'` + + (namespace ? `,namespace='` + namespace + `'` : '') + + `})`; + const [resultAutosyncDisabledPQ, loadedAutosyncDisabledPQ, errorAutosyncDisabledPQ] = + usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY, + delay: lastUpdateTime * 1000, + query: autosyncDisabledPQ, + }); + const noOfAutosyncDisabled = resultAutosyncDisabledPQ?.data?.result[0]?.value[1]; + + // Repo Queue + const repoQueuePQ = + `sum(argocd_repo_pending_request_total` + + (namespace ? `{namespace='` + namespace + `'})` : ')'); + const [resultRepoQueuePQ, loadedRepoQueuePQ, errorRepoQueuePQ] = usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY, + delay: lastUpdateTime * 1000, + query: repoQueuePQ, + }); + const noOfRepoQueue = resultRepoQueuePQ?.data?.result[0]?.value[1]; + + // Git fetch failures + const gitFetchFailedTotalPQ = + `sum(argocd_git_lsremote_fail_total` + (namespace ? `{namespace='` + namespace + `'})` : ')'); + const [resultGitFetchFailedTotalPQ, loadedGitFetchFailedTotalPQ, errorGitFetchFailedTotalPQ] = + usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY, + delay: lastUpdateTime * 1000, + query: gitFetchFailedTotalPQ, + }); + const noOfGitFetchFailedTotal = resultGitFetchFailedTotalPQ?.data?.result[0]?.value[1]; + + return ( + + + + {t('Operational metrics')} + {timeOfSuccessfulSyncs && ( + + + + )} + + + + {rowMetric( + t('Successful syncs'), + successfulSyncsPQ, + loadedSuccessfulSyncsPQ, + errorSuccessfulSyncsPQ, + t('Number of successful syncs'), + , + )} + {rowMetric( + t('Failed syncs'), + failedSyncsPQ, + loadedFailedSyncsPQ, + errorFailedSyncsPQ, + t( + 'Number of failed syncs. Apps successfully synced from a failed sync will not decrease this count.', + ), + noOfFailedSyncs ? ( + + ) : ( + + ), + )} + {rowMetric( + t('Orphaned resources'), + orphanedResourcesCountPQ, + loadedOrphanedResourcesCountPQ, + errorOrphanedResourcesCountPQ, + t('Number of orphaned resources'), + , + )} + {rowMetric( + t('Apps with Autosync enabled'), + autosyncEnabledPQ, + loadedAutosyncEnabledPQ, + errorAutosyncEnabledPQ, + t('Number of apps with autosync enabled'), + , + )} + {rowMetric( + t('Apps with Autosync disabled'), + autosyncDisabledPQ, + loadedAutosyncDisabledPQ, + errorAutosyncDisabledPQ, + t('Number of apps with autosync disabled'), + , + )} + {rowMetric( + t('Repo Queue'), + repoQueuePQ, + loadedRepoQueuePQ, + errorRepoQueuePQ, + t('Number of repos in the queue'), + , + )} + {rowMetric( + t('Git fetch failures'), + gitFetchFailedTotalPQ, + loadedGitFetchFailedTotalPQ, + errorGitFetchFailedTotalPQ, + t('Number of git fetch failures'), + noOfGitFetchFailedTotal ? ( + + ) : ( + + ), + )} + + + ); +}; + +export default OperationalMetricsCard; diff --git a/src/gitops/components/overview/reconciliation/ReconciliationCard.tsx b/src/gitops/components/overview/reconciliation/ReconciliationCard.tsx new file mode 100644 index 000000000..6ebc8508f --- /dev/null +++ b/src/gitops/components/overview/reconciliation/ReconciliationCard.tsx @@ -0,0 +1,184 @@ +import * as React from 'react'; + +import ExternalLink from '@gitops/utils/components/ExternalLink/ExternalLink'; +import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { prometheusResultToTimeSeries } from '@gitops/utils/prometheusChartData'; +import { PrometheusEndpoint, usePrometheusPoll } from '@openshift-console/dynamic-plugin-sdk'; +import { + Chart, + ChartAxis, + ChartGroup, + ChartLine, + ChartThemeColor, + ChartVoronoiContainer, +} from '@patternfly/react-charts/victory'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; + +import { + ChartCursorLine, + countTickFormat, + errorState, + getCountAxisConfig, + loadingState, + NoDataPointsFound, + OverviewCardProps, + relativeTimeTickFormat, + RelativeTimeTickLabel, + useChartContainerWidth, + Y_AXIS_STYLE, +} from '../chartHelpers'; + +// Shared dashboard-wide styles across all cards +import '../DashboardOverviewPage.scss'; + +// This chart's actual plot-area padding (reserving space for axis labels/ticks around the data). +// Shared with `voronoiPadding` below so the two can never drift apart. See comment below. +const RECONCILE_CHART_PADDING = { bottom: 50, left: 60, right: 25, top: 10 }; + +const legendData = [ + { + name: 'Reconciliation Performance (s)', + childName: 'RECONCILIATION_PERFORMANCE', // See Chartline below + symbol: { type: 'square', fill: 'var(--pf-t--global--color--nonstatus--blue--default)' }, + }, +]; + +// Self-contained "Application Reconciliation Performance" dashboard card +// See also SyncActivitCard, which is similar in construction. +const ReconciliationCard: React.FC = ({ namespace }) => { + const prometheusQuery = + 'sum(increase(argocd_app_reconcile_count{' + + (namespace ? `namespace='` + namespace + `'` : '') + + '}[24h])) or on() vector(0)'; + const [result, promLoaded, promError] = usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY_RANGE, + query: prometheusQuery, + delay: 10 * 1000, // null to not update + timespan: 24 * 60 * 60 * 1000, + samples: 240, + }); + + const chartData = React.useMemo(() => prometheusResultToTimeSeries(result), [result]); + const chartYMax = React.useMemo(() => { + const maxY = chartData.reduce((max, { y }) => Math.max(max, y), 0); + return maxY > 0 ? Math.ceil(maxY) : 10; + }, [chartData]); + + // See `getCountAxisConfig`'s comment (in `chartHelpers.tsx`) -- `domainMax` here replaces this + // chart's previous `chartYMax * 1.5` `maxDomain` multiplier; the `1.5` headroom factor itself is + // preserved as-is (just now applied *before* picking a step, not after). + const reconcileYAxisConfig = React.useMemo(() => getCountAxisConfig(chartYMax, 1.2), [chartYMax]); + + // See `useChartContainerWidth`'s comment (in `chartHelpers.tsx`) for why each chart tracks its + // own container width independently. + const [reconcileChartWidth, reconcileChartContainerRef] = useChartContainerWidth(675); + + // Adaptive tick count (replaces a fixed one-tick-per-hour `tickValues` array), matching + // Console's own `QueryBrowser.tsx`. Let Victory's underlying e3 time scale pick the best fit in + // terms of the number of ticks on the x-axis. + const reconcileXAxisTickCount = Math.round(reconcileChartWidth / 100); + + return ( + + {t('Application Reconciliation Performance (24 hours)')} + + {/* eslint-disable-next-line no-nested-ternary */} + {!promLoaded && !promError ? ( +
{loadingState()}
+ ) : promError ? ( +
+ {errorState( + t('Unable to retrieve metrics data'), + t('Error occurrend running query ono reconciliation metric: ' + promError), + )} +
+ ) : ( + <> +
+ {chartData.length === 0 ? ( + + ) : ( + `${Number.parseInt(datum.y)}`} + labelComponent={ + + new Date(datum.x).toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'medium', + hour12: true, + }) + } + /> + } + constrainToVisibleArea + mouseFollowTooltips + // Only consider x-axis for voronoi selection + voronoiDimension="x" + // This is needed so that the tooltip shows only when hovering over the chart. + // It works nicely. From AI agent: + // + // The cast is needed because PatternFly's own `ChartVoronoiContainer` type + // narrows `voronoiPadding` to `number` only, even though the underlying + // `victory-voronoi-container`/`victory-core` runtime (and even *its own* + // type, `PaddingProps`) fully supports a per-side object via + // `Helpers.getPadding` -- PatternFly's wrapper just spreads `...rest` + // through unchanged, so this is a type-definition gap, not a real runtime + // restriction. + voronoiPadding={RECONCILE_CHART_PADDING as unknown as number} + /> + } + height={190} + // Victory recalculates its layout for the real width as the card resizes. + width={reconcileChartWidth} + maxDomain={{ y: reconcileYAxisConfig.domainMax }} + minDomain={{ y: 0 }} + name="ReconciliationActivity" + padding={RECONCILE_CHART_PADDING} + // Scale must be set here, on the top-level `` and not on the nested `` below. + scale={{ x: 'time', y: 'linear' }} + themeColor={ChartThemeColor.default} + > + } + /> + + + + + + )} +
+ + {t('View in Observe/Metrics')} + + + )} +
+
+ ); +}; + +export default ReconciliationCard; diff --git a/src/gitops/components/overview/sync-activity/SyncActivityCard.tsx b/src/gitops/components/overview/sync-activity/SyncActivityCard.tsx new file mode 100644 index 000000000..153cdbf5c --- /dev/null +++ b/src/gitops/components/overview/sync-activity/SyncActivityCard.tsx @@ -0,0 +1,296 @@ +import * as React from 'react'; + +import ExternalLink from '@gitops/utils/components/ExternalLink/ExternalLink'; +import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { prometheusResultToTimeSeries } from '@gitops/utils/prometheusChartData'; +import { PrometheusEndpoint, usePrometheusPoll } from '@openshift-console/dynamic-plugin-sdk'; +import { + Chart, + ChartAxis, + ChartGroup, + ChartLine, + ChartThemeColor, + ChartVoronoiContainer, +} from '@patternfly/react-charts/victory'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; + +import { + ChartCursorLine, + countTickFormat, + errorState, + getCountAxisConfig, + loadingState, + NoDataPointsFound, + OverviewCardProps, + relativeTimeTickFormat, + RelativeTimeTickLabel, + useChartContainerWidth, + Y_AXIS_STYLE, +} from '../chartHelpers'; + +import '../DashboardOverviewPage.scss'; + +// This chart's actual plot-area padding (reserving space for axis labels/ticks around the data). +// Shared with `voronoiPadding` below so the two can never drift apart. See comment below. +const SYNC_CHART_PADDING = { bottom: 50, left: 60, right: 25, top: 10 }; + +// Self-contained "Application Sync Activity" dashboard card +const SyncActivityCard: React.FC = ({ namespace }) => { + // See Console's `QueryBrowser.tsx` when fanning one poll out across multiple queries. + // Use same strategy to compute `now` once for all queries's endTime so the time range X values are the same. + const [syncChartsEndTime, setSyncChartsEndTime] = React.useState(() => Date.now()); + React.useEffect(() => { + const interval = setInterval(() => setSyncChartsEndTime(Date.now()), 60 * 1000); + return () => clearInterval(interval); + }, []); + + const appsSyncedPQ = + `sum(argocd_app_info{sync_status='Synced'` + + (namespace ? `, namespace='` + namespace + `'` : '') + + `}) or on() vector(0)`; + const [resultAppsSyncedPQ, loadedAppsSyncedPQ, errorAppsSyncedPQ] = usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY_RANGE, + query: appsSyncedPQ, + delay: 5 * 1000, + endTime: syncChartsEndTime, + timespan: 24 * 60 * 60 * 1000, + samples: 240, + }); + + const appsOutOfSyncPQ = + `sum(argocd_app_info{sync_status='OutOfSync'` + + (namespace ? `, namespace='` + namespace + `'` : '') + + `}) or on() vector(0)`; + const [resultAppsOutOfSyncPQ, loadedAppsOutOfSyncPQ, errorAppsOutOfSyncPQ] = usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY_RANGE, + query: appsOutOfSyncPQ, + delay: 5 * 1000, + endTime: syncChartsEndTime, + timespan: 24 * 60 * 60 * 1000, + samples: 240, + }); + + // Counter for application sync history. + const numberOfSyncsPQ = + 'sum(increase(argocd_app_sync_total{' + + (namespace ? `namespace='` + namespace + `'` : '') + + '}[24h])) or on() vector(0)'; + const [resultNumberOfSyncsPQ, loadedNumberOfSyncsPQ, errorNumberOfSyncsPQ] = usePrometheusPoll({ + endpoint: PrometheusEndpoint.QUERY_RANGE, + query: numberOfSyncsPQ, + delay: 5 * 1000, + endTime: syncChartsEndTime, + timespan: 24 * 60 * 60 * 1000, + samples: 240, + }); + + const syncChartData = React.useMemo( + () => prometheusResultToTimeSeries(resultNumberOfSyncsPQ), + [resultNumberOfSyncsPQ], + ); + const syncChartYMax = React.useMemo(() => { + const maxY = syncChartData.reduce((max, { y }) => Math.max(max, y), 0); + return maxY > 0 ? Math.ceil(maxY) : 10; + }, [syncChartData]); + + const syncedChartData = React.useMemo( + () => prometheusResultToTimeSeries(resultAppsSyncedPQ), + [resultAppsSyncedPQ], + ); + const syncedChartYMax = React.useMemo(() => { + const maxY = syncedChartData.reduce((max, { y }) => Math.max(max, y), 0); + return maxY > 0 ? Math.ceil(maxY) : 10; + }, [syncedChartData]); + + const outOfSyncChartData = React.useMemo( + () => prometheusResultToTimeSeries(resultAppsOutOfSyncPQ), + [resultAppsOutOfSyncPQ], + ); + const outOfSyncChartYMax = React.useMemo(() => { + const maxY = outOfSyncChartData.reduce((max, { y }) => Math.max(max, y), 0); + return maxY > 0 ? Math.ceil(maxY) : 10; + }, [outOfSyncChartData]); + + const syncChartCombinedYMax = Math.max(syncChartYMax, syncedChartYMax, outOfSyncChartYMax); + + const syncYAxisConfig = React.useMemo( + () => getCountAxisConfig(syncChartCombinedYMax, 1.2), + [syncChartCombinedYMax], + ); + + const syncLegendData = [ + { + name: t('Syncs (24h sliding window)'), + childName: 'SYNCHRONIZATIONS_COUNT', // See Chartline below + symbol: { type: 'square', fill: 'var(--pf-t--global--color--nonstatus--blue--default)' }, + }, + { + name: t('Synced'), + childName: 'SYNCED_APPS_COUNT', // See Chartline below + symbol: { type: 'square', fill: 'var(--pf-t--global--color--nonstatus--green--default)' }, + }, + { + name: t('Out of Sync'), + childName: 'OUT_OF_SYNC_APPS_COUNT', // See Chartline below + symbol: { type: 'square', fill: 'var(--pf-t--global--color--nonstatus--yellow--default)' }, + }, + ]; + + // See `useChartContainerWidth`'s comment (in `chartHelpers.tsx`) for why each chart tracks its + // own container width independently. + const [syncChartWidth, syncChartContainerRef] = useChartContainerWidth(675); + + // Adaptive tick count (replaces a fixed one-tick-per-hour `tickValues` array), matching + // Console's own `QueryBrowser.tsx`. Let Victory's underlying e3 time scale pick the best fit in + // terms of the number of ticks on the x-axis. + const syncXAxisTickCount = Math.round(syncChartWidth / 100); + + return ( + + {t('Application Sync Activity (24 hours)')} + + {/* eslint-disable-next-line no-nested-ternary */} + {(!loadedNumberOfSyncsPQ && !errorNumberOfSyncsPQ) || + (!loadedAppsSyncedPQ && !errorAppsSyncedPQ) || + (!loadedAppsOutOfSyncPQ && !errorAppsOutOfSyncPQ) ? ( +
{loadingState()}
+ ) : /* eslint-disable-next-line no-nested-ternary */ + errorNumberOfSyncsPQ ? ( +
+ {errorState( + t('Unable to retrieve metrics data'), + t('Error occurred running query on Sync count: ' + errorNumberOfSyncsPQ), + )} +
+ ) : /* eslint-disable-next-line no-nested-ternary */ + errorAppsSyncedPQ ? ( +
+ {errorState( + t('Unable to retrieve metrics data'), + t('Error occurred running query on Synced apps: ' + errorAppsSyncedPQ), + )} +
+ ) : errorAppsOutOfSyncPQ ? ( +
+ {errorState( + t('Unable to retrieve metrics data'), + t('Error occurred running query on Out of Sync apps: ' + errorAppsOutOfSyncPQ), + )} +
+ ) : ( + <> +
+ {syncChartData.length === 0 ? ( + + ) : ( + `${Number.parseInt(datum.y)}`} + labelComponent={ + + new Date(datum.x).toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'medium', + hour12: true, + }) + } + /> + } + constrainToVisibleArea + mouseFollowTooltips + // Only consider x-axis for voronoi selection + voronoiDimension="x" + // This is needed so that the tooltip shows only when hovering over the chart. + // It works nicely. From AI agent: + // + // The cast is needed because PatternFly's own `ChartVoronoiContainer` type + // narrows `voronoiPadding` to `number` only, even though the underlying + // `victory-voronoi-container`/`victory-core` runtime (and even *its own* + // type, `PaddingProps`) fully supports a per-side object via + // `Helpers.getPadding` -- PatternFly's wrapper just spreads `...rest` + // through unchanged, so this is a type-definition gap, not a real runtime + // restriction. + voronoiPadding={SYNC_CHART_PADDING as unknown as number} + /> + } + height={190} + // Victory recalculates its layout for the real width as the card resizes. + width={syncChartWidth} + maxDomain={{ y: syncYAxisConfig.domainMax }} + minDomain={{ y: 0 }} + name="SynchronizationActivity" + padding={SYNC_CHART_PADDING} + // Scale must be set here, on the top-level `` and not on the nested `` below. + scale={{ x: 'time', y: 'linear' }} + themeColor={ChartThemeColor.default} + > + } + /> + + + + + + + + )} +
+ + + )} +
+
+ ); +}; + +export default SyncActivityCard; diff --git a/src/gitops/components/overview/sync-status/SyncStatusCard.tsx b/src/gitops/components/overview/sync-status/SyncStatusCard.tsx new file mode 100644 index 000000000..a1685a3e9 --- /dev/null +++ b/src/gitops/components/overview/sync-status/SyncStatusCard.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; + +import { ApplicationKind } from '@gitops/models/ApplicationModel'; +import { SyncStatus } from '@gitops/utils/constants'; +import { t } from '@gitops/utils/hooks/useGitOpsTranslation'; +import { K8sResourceCommon, useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { ChartDonut, ChartLegend, ChartTooltip } from '@patternfly/react-charts/victory'; +import { Card, CardBody, CardTitle } from '@patternfly/react-core'; + +import { + ChartData, + errorState, + getNamespacePath, + LegendData, + loadingState, + OverviewCardProps, + StatusChartLegendLabel, +} from '../chartHelpers'; + +// Shared dashboard-wide styles across all cards +import '../DashboardOverviewPage.scss'; + +// Self-contained "Sync status" dashboard card, that watches Applications +const SyncStatusCard: React.FC = ({ namespace }) => { + const [applications, loaded, loadError] = useK8sWatchResource({ + isList: true, + groupVersionKind: { + group: 'argoproj.io', + kind: 'Application', + version: 'v1alpha1', + }, + namespaced: !!namespace, + namespace, + }); + + const namespacePath = getNamespacePath(namespace); + const appsCount = applications.length ? applications.length : 0; + const syncStatus = applications.map( + (application: ApplicationKind) => application.status?.sync?.status, + ); + + const syncStatusCount = syncStatus.reduce((acc, status) => { + if (status === 'Synced') { + acc[SyncStatus.SYNCED] = (acc[SyncStatus.SYNCED] || 0) + 1; + } else if (status === 'OutOfSync') { + acc[SyncStatus.OUT_OF_SYNC] = (acc[SyncStatus.OUT_OF_SYNC] || 0) + 1; + } else { + acc[SyncStatus.UNKNOWN] = (acc[SyncStatus.UNKNOWN] || 0) + 1; + } + return acc; + }, {}); + + const actualSyncedCount = syncStatusCount[SyncStatus.SYNCED] || 0; + const actualOutOfSyncCount = syncStatusCount[SyncStatus.OUT_OF_SYNC] || 0; + const actualSyncUnknownCount = syncStatusCount[SyncStatus.UNKNOWN] || 0; + const syncStatusChartData: ChartData[] = [ + { x: SyncStatus.SYNCED, y: actualSyncedCount }, + { x: SyncStatus.OUT_OF_SYNC, y: actualOutOfSyncCount }, + { x: SyncStatus.UNKNOWN, y: actualSyncUnknownCount }, + ]; + const syncedPercentage = + appsCount === 0 || actualSyncedCount == 0 ? 0 : (actualSyncedCount / appsCount) * 100; + const outOfSyncPercentage = + appsCount === 0 || actualOutOfSyncCount == 0 ? 0 : (actualOutOfSyncCount / appsCount) * 100; + const syncUnknownPercentage = + appsCount === 0 || actualSyncUnknownCount == 0 ? 0 : (actualSyncUnknownCount / appsCount) * 100; + + const syncStatusLegendData: LegendData[] = [ + { + name: `Synced: ${actualSyncedCount} (${syncedPercentage.toFixed(0)}%)`, + symbol: { type: 'circle' }, + url: '/k8s/' + namespacePath + '/argoproj.io~v1alpha1~Application?rowFilter-app-sync=Synced', + linkText: `Synced`, + suffixText: `: ${actualSyncedCount} (${syncedPercentage.toFixed(0)}%)`, + }, + { + name: `OutOfSync: ${actualOutOfSyncCount} (${outOfSyncPercentage.toFixed(0)}%)`, + symbol: { type: 'circle' }, + url: + '/k8s/' + namespacePath + '/argoproj.io~v1alpha1~Application?rowFilter-app-sync=OutOfSync', + linkText: `OutOfSync`, + suffixText: `: ${actualOutOfSyncCount} (${outOfSyncPercentage.toFixed(0)}%)`, + }, + { + name: `Unknown: ${actualSyncUnknownCount} (${syncUnknownPercentage.toFixed(0)}%)`, + symbol: { type: 'circle' }, + url: '/k8s/' + namespacePath + '/argoproj.io~v1alpha1~Application?rowFilter-app-sync=Unknown', + linkText: `Unknown`, + suffixText: `: ${actualSyncUnknownCount} (${syncUnknownPercentage.toFixed(0)}%)`, + }, + ]; + + return ( + + {t('Sync status')} + + {/* eslint-disable-next-line no-nested-ternary */} + {!loaded && !loadError ? ( +
{loadingState()}
+ ) : loadError ? ( +
+ {errorState( + t('Unable to retrieve applications'), + t('Error retrieving applications: ', loadError), + )} +
+ ) : ( +
+ `${datum.x}: ${datum.y}`} + legendData={syncStatusLegendData} + legendPosition="bottom" + legendOrientation="vertical" + colorScale={[ + 'var(--pf-t--global--dark--color--status--success--100)', + 'var(--pf-t--global--dark--color--status--warning--100)', + 'rgb(204, 214, 221)', + ]} + name="SyncStatus" + padding={{ + bottom: 150, + left: 20, + right: 20, + top: 20, + }} + labelComponent={ + + } + legendComponent={ + } + style={{ labels: { fontSize: 12 } }} + symbolSpacer={8} + rowGutter={0} + /> + } + title={appsCount.toString()} + subTitle={t('Apps')} + /> +
+ )} +
+
+ ); +}; + +export default SyncStatusCard; diff --git a/src/gitops/utils/prometheusChartData.test.ts b/src/gitops/utils/prometheusChartData.test.ts new file mode 100644 index 000000000..42aca071b --- /dev/null +++ b/src/gitops/utils/prometheusChartData.test.ts @@ -0,0 +1,33 @@ +import { prometheusResultToTimeSeries } from './prometheusChartData'; + +describe('prometheusResultToTimeSeries', () => { + it('maps query_range values to chart data', () => { + const data = prometheusResultToTimeSeries({ + data: { + result: [ + { + metric: {}, + values: [ + [1_700_000_000, '2'], + [1_700_000_060, '5'], + ], + }, + ], + }, + }); + + expect(data).toHaveLength(2); + expect(data[0]).toEqual({ x: new Date(1_700_000_000_000), y: 2 }); + expect(data[1]).toEqual({ x: new Date(1_700_000_060_000), y: 5 }); + }); + + it('maps instant vector value to a single chart point', () => { + const data = prometheusResultToTimeSeries({ + data: { + result: [{ metric: {}, value: [1_700_000_000, '7'] }], + }, + }); + + expect(data).toEqual([{ x: new Date(1_700_000_000_000), y: 7 }]); + }); +}); diff --git a/src/gitops/utils/prometheusChartData.ts b/src/gitops/utils/prometheusChartData.ts new file mode 100644 index 000000000..e72b94146 --- /dev/null +++ b/src/gitops/utils/prometheusChartData.ts @@ -0,0 +1,46 @@ +/* Chart datum for PatternFly Victory charts */ +export type PrometheusChartDatum = { x: Date; y: number }; + +type PrometheusSampleTuple = [number, string]; + +type PrometheusQueryResult = { + metric?: Record; + value?: PrometheusSampleTuple; + values?: PrometheusSampleTuple[]; +}; + +type PrometheusQueryResponse = { + data?: { + result?: PrometheusQueryResult[]; + }; +}; + +const parseSample = ([timestamp, value]: PrometheusSampleTuple): PrometheusChartDatum => ({ + x: new Date(timestamp * 1000), + y: parseFloat(value), +}); + +/** + * Converts a Prometheus query_range matrix or instant vector into ChartLine data. + * Uses `values` when present (range query); otherwise a single `value` sample (instant query). + */ +export const prometheusResultToTimeSeries = ( + response: PrometheusQueryResponse | undefined, + seriesIndex = 0, +): PrometheusChartDatum[] => { + const series = response?.data?.result?.[seriesIndex]; + if (!series) { + return []; + } + + if (series.values?.length) { + return series.values.map(parseSample).filter((datum) => !Number.isNaN(datum.y)); + } + + if (series.value) { + const datum = parseSample(series.value); + return Number.isNaN(datum.y) ? [] : [datum]; + } + + return []; +};