From f8073a479d26fb3f76c39cbfc022e6300b168fcb Mon Sep 17 00:00:00 2001 From: Gianluca Mardente Date: Sat, 17 Oct 2026 11:59:51 +0200 Subject: [PATCH] feat: Show the health of the resources Sveltos monitors Cluster and profile badges and an Unhealthy resources tab Adds the dashboard side of health monitoring, on top of /clusterHealth and the healthStatus fields in ui-backend. What it shows - Cluster badge. A health badge on each cluster card in the clusters list, with the worst status of everything reported for the cluster. Clicking it opens the cluster page on the new tab. No badge means nothing is monitored, never "healthy". - Profile health column. A Health column in the Profiles tab. A profile with unhealthy resources links to the new tab, already filtered by that profile. - Unhealthy resources tab. A new tab on the cluster page: a paginated table with group, version, kind, namespace, name, status, message and the owning profile. It lists resources from profiles with monitoring enabled first, then resources reported by any other HealthCheck, labelled HealthCheck: . Filters are profile kind and name, and resource kind, namespace and name. - Empty states. The table says why it is empty (nothing monitored, no report yet, all healthy, or no match for the filters), so an empty table is never read as "all healthy". Supporting changes - The active tab of the cluster page and the Unhealthy resources filters now live in the URL, so the tab and the filtered list can be linked to. Leaving a tab clears its filters, so a profile filter does not follow the user back to the Profiles tab. - New helpers in src/utils, each with unit tests: addonTabs, clusterHealthPath, pickSearchParams and healthStatus. Strings added in en, fr and ar. --- Makefile | 2 +- manifest/manifest.yaml | 4 +- src/api-client/endpoints.ts | 1 + .../ui/data-display/health-badge.tsx | 77 ++++++++ .../ui/data-display/health-status-label.tsx | 35 ++++ src/locales/ar.json | 15 +- src/locales/en.json | 15 +- src/locales/fr.json | 15 +- .../components/ClusterHealthView.tsx | 48 +++++ .../components/addonsTable/Addons.tsx | 73 ++++---- .../components/addonsTable/AddonsTable.tsx | 6 +- .../components/addonsTable/DriftDialog.tsx | 2 + .../addonsTable/config/Columns.test.ts | 15 ++ .../components/addonsTable/config/Columns.ts | 8 +- .../addonsTable/rows/ProfileRow.tsx | 40 ++++- .../addonsTable/utils/addonsTableUtils.ts | 30 ++-- .../hooks/useClusterHealth.ts | 52 ++++++ .../hooks/useClusterTableInfo.ts | 4 +- .../clusters-list/components/ClusterCard.tsx | 7 + .../clusters-list/components/ClusterList.tsx | 11 ++ src/modules/health/HealthResourcesTable.tsx | 164 ++++++++++++++++++ src/modules/health/healthColumns.ts | 37 ++++ src/modules/health/healthSearchConfig.ts | 11 ++ src/types/addon.types.ts | 6 + src/types/addonTable.types.ts | 1 + src/types/cluster.types.ts | 5 + src/types/health.types.ts | 36 ++++ src/utils/addonTabs.test.ts | 18 ++ src/utils/addonTabs.ts | 22 +++ src/utils/clusterHealthPath.test.ts | 26 +++ src/utils/clusterHealthPath.ts | 24 +++ src/utils/healthStatus.test.ts | 54 ++++++ src/utils/healthStatus.ts | 62 +++++++ src/utils/pickSearchParams.test.ts | 34 ++++ src/utils/pickSearchParams.ts | 24 +++ 35 files changed, 930 insertions(+), 54 deletions(-) create mode 100644 src/lib/components/ui/data-display/health-badge.tsx create mode 100644 src/lib/components/ui/data-display/health-status-label.tsx create mode 100644 src/modules/clusters/cluster-information/components/ClusterHealthView.tsx create mode 100644 src/modules/clusters/cluster-information/components/addonsTable/config/Columns.test.ts create mode 100644 src/modules/clusters/cluster-information/hooks/useClusterHealth.ts create mode 100644 src/modules/health/HealthResourcesTable.tsx create mode 100644 src/modules/health/healthColumns.ts create mode 100644 src/modules/health/healthSearchConfig.ts create mode 100644 src/types/health.types.ts create mode 100644 src/utils/addonTabs.test.ts create mode 100644 src/utils/addonTabs.ts create mode 100644 src/utils/clusterHealthPath.test.ts create mode 100644 src/utils/clusterHealthPath.ts create mode 100644 src/utils/healthStatus.test.ts create mode 100644 src/utils/healthStatus.ts create mode 100644 src/utils/pickSearchParams.test.ts create mode 100644 src/utils/pickSearchParams.ts diff --git a/Makefile b/Makefile index 9f5d44b..4687b5a 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ IMAGE_NAME ?= dashboard ARCH ?= $(shell go env GOARCH) OS ?= $(shell uname -s | tr A-Z a-z) export CONTROLLER_IMG ?= $(REGISTRY)/$(IMAGE_NAME) -TAG ?= v1.16.0 +TAG ?= main # Directories. TOOLS_DIR := hack/tools diff --git a/manifest/manifest.yaml b/manifest/manifest.yaml index f0d582f..6b86792 100644 --- a/manifest/manifest.yaml +++ b/manifest/manifest.yaml @@ -15,12 +15,12 @@ spec: spec: containers: - name: dashboard - image: docker.io/projectsveltos/dashboard:v1.16.0 + image: docker.io/projectsveltos/dashboard:main ports: - containerPort: 5173 env: - name: VITE_APP_VERSION - value: v1.16.0 + value: main --- apiVersion: v1 kind: Service diff --git a/src/api-client/endpoints.ts b/src/api-client/endpoints.ts index c3dd22b..3cfa51e 100644 --- a/src/api-client/endpoints.ts +++ b/src/api-client/endpoints.ts @@ -2,6 +2,7 @@ export const API_ENDPOINTS = { RESOURCES: "/resources", HELM_CHART: "/helmcharts", PROFILE_STATUSES: "/getClusterStatus", + CLUSTER_HEALTH: "/clusterHealth", CAPI_CLUSTERS: "/capiclusters", SVELTOS_CLUSTERS: "/sveltosclusters", PROFILES: "/profiles", diff --git a/src/lib/components/ui/data-display/health-badge.tsx b/src/lib/components/ui/data-display/health-badge.tsx new file mode 100644 index 0000000..884b84e --- /dev/null +++ b/src/lib/components/ui/data-display/health-badge.tsx @@ -0,0 +1,77 @@ +import { MouseEvent } from "react"; +import { + CheckCircle2, + CircleDashed, + LucideIcon, + PauseCircle, + Timer, + XCircle, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/lib/components/ui/data-display/tooltip"; +import { HealthStatus } from "@/types/health.types"; +import { healthStatusStyle } from "@/utils/healthStatus"; + +const icons: Record = { + Healthy: CheckCircle2, + Progressing: Timer, + Suspended: PauseCircle, + Degraded: XCircle, + NoData: CircleDashed, +}; + +interface HealthBadgeProps { + // Nothing is shown when the status is unknown: the resources are not monitored. + status?: HealthStatus; + // When set the badge is a button, for instance to open the unhealthy resources. + onClick?: () => void; +} + +// HealthBadge shows the health reported by health monitoring: the resources a profile deploys and any other +// HealthCheck. It is shown for a monitored cluster or profile only (a profile with monitoring enabled, or a +// cluster with health reports), so a missing badge means "not monitored", never "healthy". +export const HealthBadge = ({ status, onClick }: HealthBadgeProps) => { + const { t } = useTranslation(); + if (!status) { + return null; + } + + const Icon = icons[status]; + const style = healthStatusStyle[status]; + const icon = ; + + const handleClick = (event: MouseEvent) => { + // The badge sits in clickable cards and rows + event.stopPropagation(); + onClick?.(); + }; + + return ( + + + {onClick ? ( + + ) : ( + {icon} + )} + + +

{t("common.health_tooltip", { status: t(style.labelKey) })}

+
+
+ ); +}; diff --git a/src/lib/components/ui/data-display/health-status-label.tsx b/src/lib/components/ui/data-display/health-status-label.tsx new file mode 100644 index 0000000..2e1d48b --- /dev/null +++ b/src/lib/components/ui/data-display/health-status-label.tsx @@ -0,0 +1,35 @@ +import { useTranslation } from "react-i18next"; + +import { Badge } from "@/lib/components/ui/data-display/badge"; +import { HealthStatus } from "@/types/health.types"; +import { healthStatusStyle } from "@/utils/healthStatus"; + +interface HealthStatusLabelProps { + status: HealthStatus; + // When set the label is a button, for instance to open the unhealthy resources. + onClick?: () => void; +} + +// HealthStatusLabel shows a health status as a coloured badge with its name. +export const HealthStatusLabel = ({ + status, + onClick, +}: HealthStatusLabelProps) => { + const { t } = useTranslation(); + const style = healthStatusStyle[status]; + const badge = {t(style.labelKey)}; + + if (!onClick) { + return badge; + } + + return ( + + ); +}; diff --git a/src/locales/ar.json b/src/locales/ar.json index 19b3bed..9149276 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -136,6 +136,19 @@ "helm_release": "إصدار Helm", "drift_history": "سجل الانحراف", "drift_last_detected": "آخر انحراف تم اكتشافه في", - "detected": "تم الاكتشاف" + "detected": "تم الاكتشاف", + "health_healthy": "سليم", + "health_progressing": "قيد التقدم", + "health_degraded": "متدهور", + "health_suspended": "معلق", + "health_no_data": "لا توجد بيانات", + "health_tooltip": "مراقبة الصحة: {{status}}", + "health_message": "الرسالة", + "health_check": "HealthCheck: {{name}}", + "health_not_enabled": "لا شيء قيد المراقبة على هذا الكلاستر: لا يوجد بروفايل مفعّل لمراقبة الصحة ولا توجد تقارير صحة.", + "health_no_data_yet": "مراقبة الصحة مفعّلة، لكن لم يتم استلام أي تقرير بعد.", + "health_all_healthy": "جميع الموارد المراقبة سليمة.", + "health_no_match": "لا توجد موارد غير سليمة تطابق عوامل التصفية.", + "health": "الصحة" } } diff --git a/src/locales/en.json b/src/locales/en.json index 108c00d..b56c4c4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -187,6 +187,19 @@ "helm_release": "Helm release", "drift_history": "Drift history", "drift_last_detected": "Last drift detected at", - "detected": "Detected" + "detected": "Detected", + "health_healthy": "Healthy", + "health_progressing": "Progressing", + "health_degraded": "Degraded", + "health_suspended": "Suspended", + "health_no_data": "No data", + "health_tooltip": "Health monitoring: {{status}}", + "health_message": "Message", + "health_check": "HealthCheck: {{name}}", + "health_not_enabled": "Nothing is monitored on this cluster: no profile has health monitoring enabled and there are no health reports.", + "health_no_data_yet": "Health monitoring is enabled, but no report has been received yet.", + "health_all_healthy": "All the monitored resources are healthy.", + "health_no_match": "No unhealthy resources match the filters.", + "health": "Health" } } diff --git a/src/locales/fr.json b/src/locales/fr.json index b5cdd4a..e6c64a2 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -137,6 +137,19 @@ "helm_release": "Release Helm", "drift_history": "Historique des dérives", "drift_last_detected": "Dernière dérive détectée le", - "detected": "Détectée" + "detected": "Détectée", + "health_healthy": "Sain", + "health_progressing": "En cours", + "health_degraded": "Dégradé", + "health_suspended": "Suspendu", + "health_no_data": "Aucune donnée", + "health_tooltip": "Surveillance de santé : {{status}}", + "health_message": "Message", + "health_check": "HealthCheck : {{name}}", + "health_not_enabled": "Rien n'est surveillé sur ce cluster : aucun profil n'a activé la surveillance de santé et aucun rapport de santé n'existe.", + "health_no_data_yet": "La surveillance de santé est activée, mais aucun rapport n'a encore été reçu.", + "health_all_healthy": "Toutes les ressources surveillées sont saines.", + "health_no_match": "Aucune ressource défaillante ne correspond aux filtres.", + "health": "Santé" } } diff --git a/src/modules/clusters/cluster-information/components/ClusterHealthView.tsx b/src/modules/clusters/cluster-information/components/ClusterHealthView.tsx new file mode 100644 index 0000000..4064ffb --- /dev/null +++ b/src/modules/clusters/cluster-information/components/ClusterHealthView.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; + +import { appConfig } from "@/config/app"; +import { ClusterType } from "@/types/cluster.types"; +import { HealthResourcesTable } from "@/modules/health/HealthResourcesTable"; +import { clusterHealthColumns } from "@/modules/health/healthColumns"; +import { healthSearchConfig } from "@/modules/health/healthSearchConfig"; +import { useClusterHealth } from "@/modules/clusters/cluster-information/hooks/useClusterHealth"; +import { pickSearchParams } from "@/utils/pickSearchParams"; + +// ClusterHealthView lists the resources of the cluster of the page which are not healthy. The filters live in +// the URL, like the other addons tabs: opened from the health badge of a profile, the profile filter is already +// set, and clearing it lists the unhealthy resources of every profile. +export function ClusterHealthView() { + const { tab: clusterType, name, namespace } = useParams(); + const [searchParams] = useSearchParams(); + const [page, setPage] = useState(appConfig.defaultPage); + + const filters = pickSearchParams(healthSearchConfig, searchParams); + const filtersKey = JSON.stringify(filters); + + // Another page of the previous filters would make no sense + useEffect(() => { + setPage(appConfig.defaultPage); + }, [filtersKey]); + + const query = useClusterHealth( + namespace ?? "", + name ?? "", + clusterType as ClusterType, + page, + filters, + ); + + return ( + 0} + loading={query.isLoading} + page={page} + onPageChange={setPage} + columns={clusterHealthColumns} + /> + ); +} diff --git a/src/modules/clusters/cluster-information/components/addonsTable/Addons.tsx b/src/modules/clusters/cluster-information/components/addonsTable/Addons.tsx index c70f211..3ad9676 100644 --- a/src/modules/clusters/cluster-information/components/addonsTable/Addons.tsx +++ b/src/modules/clusters/cluster-information/components/addonsTable/Addons.tsx @@ -17,30 +17,49 @@ import { } from "@/lib/components/ui/navigation/tabs"; import { AddonsTable } from "@/modules/clusters/cluster-information/components/addonsTable/AddonsTable"; -import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; -import { AddonTableData, addonTypes, AddonTypes } from "@/types/addon.types"; +import { + AddonTableData, + addonTypes, + AddonTypes, + TableAddonTypes, +} from "@/types/addon.types"; import { appConfig } from "@/config/app"; import { SearchQueryParamInput } from "@/lib/components/ui/inputs/SearchQueryParamInput"; -import { typeConfig } from "@/modules/clusters/cluster-information/components/addonsTable/config/typeConfig"; -import { hasSearchConfig } from "@/modules/clusters/cluster-information/components/addonsTable/utils/addonsTableUtils"; +import { getSearchConfig } from "@/modules/clusters/cluster-information/components/addonsTable/utils/addonsTableUtils"; +import { ClusterHealthView } from "@/modules/clusters/cluster-information/components/ClusterHealthView"; +import { omitSearchParams } from "@/utils/pickSearchParams"; +import { + ADDONS_TAB_PARAM, + addonTabFromParam, + addonTabSlug, +} from "@/utils/addonTabs"; import { useTranslation } from "react-i18next"; interface ResourceTableProps { - addonsData: Record; + addonsData: Record; loading: boolean; - setPage: (page: number, type: AddonTypes) => void; + setPage: (page: number, type: TableAddonTypes) => void; } export function Addons({ addonsData, setPage, loading }: ResourceTableProps) { const { t } = useTranslation(); - /* By default, we show helm charts , this will be hardcoded for now */ - const [activeTab, setActiveTab] = useState(AddonTypes.HELM); + /* The tab is in the URL, so it can be linked to. By default, we show helm charts */ + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = addonTabFromParam(searchParams.get(ADDONS_TAB_PARAM)); const handleChangeTab = (tab: AddonTypes) => { - setActiveTab(tab); - setPage(appConfig.defaultPage, tab); + // The filters belong to the tab being left: some tabs share filters (a profile health badge opens the + // unhealthy resources already filtered by profile), which must not follow the user back to the Profiles tab + const next = omitSearchParams(getSearchConfig(activeTab), searchParams); + next.set(ADDONS_TAB_PARAM, addonTabSlug(tab)); + setSearchParams(next, { replace: true }); + // The unhealthy resources tab keeps its own page + if (tab !== AddonTypes.UNHEALTHY) { + setPage(appConfig.defaultPage, tab); + } }; return (
@@ -79,35 +98,29 @@ export function Addons({ addonsData, setPage, loading }: ResourceTableProps) { {addonTypes.map((tab: AddonTypes) => ( - setActiveTab(tab)} - > + {tab} ))} + {/* Remounted per tab, so its fields start from the URL (a badge can open a tab already filtered) */} {addonTypes.map((type: AddonTypes) => ( - + {type === AddonTypes.UNHEALTHY ? ( + + ) : ( + + )} ))} diff --git a/src/modules/clusters/cluster-information/components/addonsTable/AddonsTable.tsx b/src/modules/clusters/cluster-information/components/addonsTable/AddonsTable.tsx index 64e9d96..a09ad06 100644 --- a/src/modules/clusters/cluster-information/components/addonsTable/AddonsTable.tsx +++ b/src/modules/clusters/cluster-information/components/addonsTable/AddonsTable.tsx @@ -18,12 +18,12 @@ import { useTranslation } from "react-i18next"; import { FC, useState } from "react"; import { getRowsAndTotal } from "@/modules/clusters/cluster-information/components/addonsTable/utils/addonsTableUtils"; import { typeConfig } from "@/modules/clusters/cluster-information/components/addonsTable/config/typeConfig"; -import { AddonTableData, AddonTypes } from "@/types/addon.types"; +import { AddonTableData, TableAddonTypes } from "@/types/addon.types"; interface AddonsTableProps { data: AddonTableData; - type: AddonTypes; - setPage: (page: number, type: AddonTypes) => void; + type: TableAddonTypes; + setPage: (page: number, type: TableAddonTypes) => void; loading: boolean; } diff --git a/src/modules/clusters/cluster-information/components/addonsTable/DriftDialog.tsx b/src/modules/clusters/cluster-information/components/addonsTable/DriftDialog.tsx index c351a7a..1c12245 100644 --- a/src/modules/clusters/cluster-information/components/addonsTable/DriftDialog.tsx +++ b/src/modules/clusters/cluster-information/components/addonsTable/DriftDialog.tsx @@ -67,6 +67,8 @@ export const DriftDialog = ({ featureID, history }: DriftDialogProps) => {
))} diff --git a/src/modules/health/HealthResourcesTable.tsx b/src/modules/health/HealthResourcesTable.tsx new file mode 100644 index 0000000..b2ea176 --- /dev/null +++ b/src/modules/health/HealthResourcesTable.tsx @@ -0,0 +1,164 @@ +import { useTranslation } from "react-i18next"; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/lib/components/ui/data-display/table"; +import { Badge } from "@/lib/components/ui/data-display/badge"; +import { HealthStatusLabel } from "@/lib/components/ui/data-display/health-status-label"; +import { LoadingTableRow } from "@/lib/components/ui/feedback/loadingTableRow"; +import { usePagination } from "@/hooks/usePagination"; +import { appConfig } from "@/config/app"; +import { ClusterHealthState, HealthResource } from "@/types/health.types"; +import { healthEmptyStateKey } from "@/utils/healthStatus"; +import { HealthColumn, HealthColumnKey } from "@/modules/health/healthColumns"; + +interface HealthResourcesTableProps { + rows: HealthResource[]; + total: number; + // Health of the cluster (or of what the rows come from): tells what an empty table means + state?: ClusterHealthState; + hasFilters: boolean; + loading: boolean; + page: number; + onPageChange: (page: number) => void; + columns: HealthColumn[]; +} + +// HealthResourcesTable lists resources which are not healthy. It knows nothing about where the rows come from: +// the cluster page feeds it with the resources of a cluster, and any other view (for instance one listing +// the resources of all the clusters) can feed it with its own rows and columns. +export const HealthResourcesTable = ({ + rows, + total, + state, + hasFilters, + loading, + page, + onPageChange, + columns, +}: HealthResourcesTableProps) => { + const { t } = useTranslation(); + const [PaginationUI] = usePagination( + total, + page, + onPageChange, + appConfig.defaultTableSize, + ); + + return ( + <> + + + + {columns.map((column) => ( + + {t(column.label)} + + ))} + + + {loading ? ( + + ) : ( + + {rows.length > 0 ? ( + rows.map((row, index) => ( + + )) + ) : ( + + + {t(healthEmptyStateKey(state, hasFilters))} + + + )} + + )} +
+
+ +
+ + ); +}; + +interface HealthResourceRowProps { + row: HealthResource; + columns: HealthColumn[]; +} + +const HealthResourceRow = ({ row, columns }: HealthResourceRowProps) => ( + + {columns.map((column) => ( + // max-w-none: the cell clips at 6rem otherwise, which cuts names and profiles + + + + ))} + +); + +const HealthCell = ({ + row, + columnKey, +}: { + row: HealthResource; + columnKey: HealthColumnKey; +}) => { + const { t } = useTranslation(); + + switch (columnKey) { + case "cluster": + return <>{`${row.clusterNamespace ?? ""}/${row.clusterName ?? ""}`}; + case "group": + return <>{row.group}; + case "version": + return <>{row.version}; + case "kind": + return <>{row.kind}; + case "namespace": + return <>{row.namespace}; + case "name": + return <>{row.name}; + case "status": + return ; + case "message": + return ( + + {row.message} + + ); + case "profiles": + if (row.profiles.length === 0 && row.healthCheckName) { + // Reported by a HealthCheck no profile owns + return ( + + {t("common.health_check", { name: row.healthCheckName })} + + ); + } + return ( + <> + {row.profiles.map((profile) => ( + + {`${profile.kind}/${profile.name}`} + + ))} + + ); + } +}; diff --git a/src/modules/health/healthColumns.ts b/src/modules/health/healthColumns.ts new file mode 100644 index 0000000..d5b0962 --- /dev/null +++ b/src/modules/health/healthColumns.ts @@ -0,0 +1,37 @@ +export type HealthColumnKey = + | "cluster" + | "group" + | "version" + | "kind" + | "namespace" + | "name" + | "status" + | "message" + | "profiles"; + +export interface HealthColumn { + key: HealthColumnKey; + // i18n key + label: string; + className?: string; +} + +// The unhealthy resources of one cluster +export const clusterHealthColumns: HealthColumn[] = [ + { key: "group", label: "common.group", className: "hidden sm:table-cell" }, + { + key: "version", + label: "common.version", + className: "hidden md:table-cell", + }, + { key: "kind", label: "common.kind" }, + { key: "namespace", label: "common.namespace" }, + { key: "name", label: "common.name" }, + { key: "status", label: "common.status" }, + { + key: "message", + label: "common.health_message", + className: "min-w-48 whitespace-normal", + }, + { key: "profiles", label: "common.profile" }, +]; diff --git a/src/modules/health/healthSearchConfig.ts b/src/modules/health/healthSearchConfig.ts new file mode 100644 index 0000000..0fac1bb --- /dev/null +++ b/src/modules/health/healthSearchConfig.ts @@ -0,0 +1,11 @@ +// Filters of a list of unhealthy resources. The keys are the query parameters of ui-backend, and of the URL. +export const healthSearchConfig = [ + { key: "profile_kind", placeholder: "common.search_profile_kind" }, + { key: "profile_name", placeholder: "common.search_profile_name" }, + { key: "resource_kind", placeholder: "common.search_resource_kind" }, + { + key: "resource_namespace", + placeholder: "common.search_resource_namespace", + }, + { key: "resource_name", placeholder: "common.search_resource_name" }, +]; diff --git a/src/types/addon.types.ts b/src/types/addon.types.ts index fafc770..a406316 100644 --- a/src/types/addon.types.ts +++ b/src/types/addon.types.ts @@ -1,10 +1,14 @@ import { AddonTableTypes } from "@/types/addonTable.types"; +import { HealthStatus } from "@/types/health.types"; export enum AddonTypes { HELM = "Helm Charts", RESOURCE = "Resources", PROFILE = "Profiles", + UNHEALTHY = "Unhealthy resources", } +// The tabs listing addons deployed on the cluster. The unhealthy resources tab loads its own data. +export type TableAddonTypes = Exclude; export type AddonTableData = { helmReleases?: AddonData[]; totalHelmReleases?: number; @@ -29,6 +33,8 @@ export type DriftHistory = { }; export type AddonData = { failureMessage?: string; + // Health of the profile on the cluster. Only set on rows of profiles with health monitoring enabled. + healthStatus?: HealthStatus; featureID?: string; driftHistory?: DriftHistory; icon?: string; diff --git a/src/types/addonTable.types.ts b/src/types/addonTable.types.ts index 3e21772..6c7ba5a 100644 --- a/src/types/addonTable.types.ts +++ b/src/types/addonTable.types.ts @@ -12,6 +12,7 @@ export enum AddonTableTypes { ICON = "icon", FAILURE_MESSAGE = "failureMessage", DRIFT = "driftHistory", + HEALTH = "healthStatus", } export interface AddonColumn { label: string; diff --git a/src/types/cluster.types.ts b/src/types/cluster.types.ts index 9796713..bd3d993 100644 --- a/src/types/cluster.types.ts +++ b/src/types/cluster.types.ts @@ -1,3 +1,5 @@ +import { HealthStatus } from "@/types/health.types"; + export type SveltosClusterType = "SveltosCluster"; export type ClusterApiType = "ClusterAPI"; export type ClusterType = SveltosClusterType | ClusterApiType; @@ -13,6 +15,9 @@ export interface ClusterInfoType { hasIssues?: boolean; isProvisioning?: boolean; hasOutdatedHelmCharts?: boolean; + // Health reported for the cluster. Absent when nothing monitors it: no profile with health monitoring + // enabled and no health report. + healthStatus?: HealthStatus; clusterInfo: { labels: Label; version: string; diff --git a/src/types/health.types.ts b/src/types/health.types.ts new file mode 100644 index 0000000..e58fb24 --- /dev/null +++ b/src/types/health.types.ts @@ -0,0 +1,36 @@ +// Health of the resources Sveltos deployed, as reported by ui-backend (health monitoring). + +export type HealthStatus = + "NoData" | "Healthy" | "Suspended" | "Progressing" | "Degraded"; + +// State of a whole cluster in /clusterHealth: NotEnabled means no profile monitors the cluster. +export type ClusterHealthState = HealthStatus | "NotEnabled"; + +export type HealthProfileRef = { + kind: string; + namespace?: string; + name: string; +}; + +export type HealthResource = { + group: string; + version: string; + kind: string; + namespace?: string; + name: string; + status: HealthStatus; + message?: string; + // Profiles owning the HealthCheck that reported the resource. Empty when none does. + profiles: HealthProfileRef[]; + // Set only when no profile owns the HealthCheck. + healthCheckName?: string; + // Set by views listing resources of several clusters. + clusterNamespace?: string; + clusterName?: string; +}; + +export type ClusterHealthResponse = { + state: ClusterHealthState; + totalResources: number; + resources: HealthResource[]; +}; diff --git a/src/utils/addonTabs.test.ts b/src/utils/addonTabs.test.ts new file mode 100644 index 0000000..3fd076a --- /dev/null +++ b/src/utils/addonTabs.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { AddonTypes } from "@/types/addon.types"; +import { addonTabFromParam, addonTabSlug } from "@/utils/addonTabs"; + +describe("addon tabs in the URL", () => { + it("round-trips every tab", () => { + Object.values(AddonTypes).forEach((tab) => { + expect(addonTabFromParam(addonTabSlug(tab))).toBe(tab); + }); + }); + + it("opens Helm charts when the parameter is missing or unknown", () => { + expect(addonTabFromParam(null)).toBe(AddonTypes.HELM); + expect(addonTabFromParam("")).toBe(AddonTypes.HELM); + expect(addonTabFromParam("nope")).toBe(AddonTypes.HELM); + }); +}); diff --git a/src/utils/addonTabs.ts b/src/utils/addonTabs.ts new file mode 100644 index 0000000..8e74ea5 --- /dev/null +++ b/src/utils/addonTabs.ts @@ -0,0 +1,22 @@ +import { AddonTypes } from "@/types/addon.types"; + +// The addons tab of the cluster page is kept in the URL, so other pages (the cluster card, a profile +// health badge) can link to it. +export const ADDONS_TAB_PARAM = "addons_tab"; + +const slugs: Record = { + [AddonTypes.HELM]: "helm", + [AddonTypes.RESOURCE]: "resources", + [AddonTypes.PROFILE]: "profiles", + [AddonTypes.UNHEALTHY]: "unhealthy", +}; + +export const addonTabSlug = (tab: AddonTypes): string => slugs[tab]; + +// addonTabFromParam returns the tab named by the URL parameter. Helm charts is the default. +export const addonTabFromParam = (value: string | null): AddonTypes => { + const found = (Object.keys(slugs) as AddonTypes[]).find( + (tab) => slugs[tab] === value, + ); + return found ?? AddonTypes.HELM; +}; diff --git a/src/utils/clusterHealthPath.test.ts b/src/utils/clusterHealthPath.test.ts new file mode 100644 index 0000000..7ce3652 --- /dev/null +++ b/src/utils/clusterHealthPath.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { buildClusterHealthPath } from "@/utils/clusterHealthPath"; + +describe("buildClusterHealthPath", () => { + it("opens the unhealthy resources of the cluster", () => { + expect(buildClusterHealthPath("sveltos", "default", "mgmt")).toBe( + "/sveltos/cluster/sveltos/default/mgmt?addons_tab=unhealthy", + ); + }); + + it("sets the profile filter the user can then edit", () => { + const path = buildClusterHealthPath("sveltos", "default", "mgmt", { + kind: "ClusterProfile", + name: "deploy-kyverno", + }); + + const [route, query] = path.split("?"); + expect(route).toBe("/sveltos/cluster/sveltos/default/mgmt"); + + const params = new URLSearchParams(query); + expect(params.get("addons_tab")).toBe("unhealthy"); + expect(params.get("profile_kind")).toBe("ClusterProfile"); + expect(params.get("profile_name")).toBe("deploy-kyverno"); + }); +}); diff --git a/src/utils/clusterHealthPath.ts b/src/utils/clusterHealthPath.ts new file mode 100644 index 0000000..f3300a4 --- /dev/null +++ b/src/utils/clusterHealthPath.ts @@ -0,0 +1,24 @@ +import { ADDONS_TAB_PARAM, addonTabSlug } from "@/utils/addonTabs"; +import { AddonTypes } from "@/types/addon.types"; + +type ProfileFilter = { + kind: string; + name: string; +}; + +// buildClusterHealthPath returns the path of the cluster page opened on the unhealthy resources, optionally +// filtered by profile. clusterTab is the cluster type segment of the cluster page route. +export const buildClusterHealthPath = ( + clusterTab: string | undefined, + namespace: string, + name: string, + profile?: ProfileFilter, +): string => { + const params = new URLSearchParams(); + params.set(ADDONS_TAB_PARAM, addonTabSlug(AddonTypes.UNHEALTHY)); + if (profile) { + params.set("profile_kind", profile.kind); + params.set("profile_name", profile.name); + } + return `/sveltos/cluster/${clusterTab}/${namespace}/${name}?${params.toString()}`; +}; diff --git a/src/utils/healthStatus.test.ts b/src/utils/healthStatus.test.ts new file mode 100644 index 0000000..42ecb91 --- /dev/null +++ b/src/utils/healthStatus.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { healthEmptyStateKey, isUnhealthy } from "@/utils/healthStatus"; + +describe("isUnhealthy", () => { + it("is true for the statuses a resource is listed with", () => { + expect(isUnhealthy("Progressing")).toBe(true); + expect(isUnhealthy("Suspended")).toBe(true); + expect(isUnhealthy("Degraded")).toBe(true); + }); + + it("is false for Healthy, NoData and an unknown status", () => { + expect(isUnhealthy("Healthy")).toBe(false); + expect(isUnhealthy("NoData")).toBe(false); + expect(isUnhealthy(undefined)).toBe(false); + }); +}); + +describe("healthEmptyStateKey", () => { + it("never reads an unmonitored cluster or a missing report as healthy", () => { + expect(healthEmptyStateKey("NotEnabled", false)).toBe( + "common.health_not_enabled", + ); + expect(healthEmptyStateKey("NoData", false)).toBe( + "common.health_no_data_yet", + ); + // the filters do not change what the cluster state says + expect(healthEmptyStateKey("NotEnabled", true)).toBe( + "common.health_not_enabled", + ); + }); + + it("says everything is healthy only for a healthy cluster with no filters", () => { + expect(healthEmptyStateKey("Healthy", false)).toBe( + "common.health_all_healthy", + ); + }); + + it("says nothing matches when filters are set, or when the cluster is not healthy", () => { + expect(healthEmptyStateKey("Healthy", true)).toBe("common.health_no_match"); + expect(healthEmptyStateKey("Degraded", false)).toBe( + "common.health_no_match", + ); + expect(healthEmptyStateKey("Degraded", true)).toBe( + "common.health_no_match", + ); + }); + + it("does not claim the cluster is healthy when there is no answer", () => { + expect(healthEmptyStateKey(undefined, false)).toBe( + "common.health_no_data_yet", + ); + }); +}); diff --git a/src/utils/healthStatus.ts b/src/utils/healthStatus.ts new file mode 100644 index 0000000..6c95898 --- /dev/null +++ b/src/utils/healthStatus.ts @@ -0,0 +1,62 @@ +import { ClusterHealthState, HealthStatus } from "@/types/health.types"; + +type HealthStatusStyle = { + // i18n key of the status name + labelKey: string; + // text colour, for icons + textClass: string; + // background colour, for badges + badgeClass: string; +}; + +export const healthStatusStyle: Record = { + Healthy: { + labelKey: "common.health_healthy", + textClass: "text-green-500/70", + badgeClass: "bg-green-500", + }, + Progressing: { + labelKey: "common.health_progressing", + textClass: "text-amber-500", + badgeClass: "bg-amber-500", + }, + Suspended: { + labelKey: "common.health_suspended", + textClass: "text-amber-400", + badgeClass: "bg-amber-400", + }, + Degraded: { + labelKey: "common.health_degraded", + textClass: "text-red-500", + badgeClass: "bg-red-500", + }, + NoData: { + labelKey: "common.health_no_data", + textClass: "text-slate-400", + badgeClass: "bg-gray-500", + }, +}; + +// isUnhealthy is true for the statuses a resource can be listed with: anything but Healthy and NoData. +export const isUnhealthy = (status: HealthStatus | undefined): boolean => + status === "Progressing" || status === "Suspended" || status === "Degraded"; + +// healthEmptyStateKey returns the i18n key of what to tell when the list of unhealthy resources is empty. +// The text depends on the state of the cluster, so an empty table is never read as "all healthy" when +// nothing is monitored or no report arrived yet. +export const healthEmptyStateKey = ( + state: ClusterHealthState | undefined, + hasFilters: boolean, +): string => { + if (state === "NotEnabled") { + return "common.health_not_enabled"; + } + // No answer (yet, or it failed) is not "healthy" either + if (state === undefined || state === "NoData") { + return "common.health_no_data_yet"; + } + if (hasFilters || isUnhealthy(state as HealthStatus)) { + return "common.health_no_match"; + } + return "common.health_all_healthy"; +}; diff --git a/src/utils/pickSearchParams.test.ts b/src/utils/pickSearchParams.test.ts new file mode 100644 index 0000000..e57cb09 --- /dev/null +++ b/src/utils/pickSearchParams.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { omitSearchParams, pickSearchParams } from "@/utils/pickSearchParams"; + +describe("pickSearchParams", () => { + const config = [{ key: "profile_name" }, { key: "resource_kind" }]; + + it("keeps only the parameters of the config which are set", () => { + const params = new URLSearchParams( + "profile_name=p&resource_kind=&other=x&addons_tab=unhealthy", + ); + expect(pickSearchParams(config, params)).toEqual({ profile_name: "p" }); + }); + + it("returns nothing when no parameter is set", () => { + expect(pickSearchParams(config, new URLSearchParams())).toEqual({}); + }); +}); + +describe("omitSearchParams", () => { + it("removes the parameters of the config and keeps the others", () => { + const params = new URLSearchParams( + "profile_name=p&resource_kind=Deployment&addons_tab=unhealthy", + ); + const next = omitSearchParams( + [{ key: "profile_name" }, { key: "resource_kind" }], + params, + ); + + expect(next.toString()).toBe("addons_tab=unhealthy"); + // the original is left alone + expect(params.get("profile_name")).toBe("p"); + }); +}); diff --git a/src/utils/pickSearchParams.ts b/src/utils/pickSearchParams.ts new file mode 100644 index 0000000..5636553 --- /dev/null +++ b/src/utils/pickSearchParams.ts @@ -0,0 +1,24 @@ +// pickSearchParams returns the parameters of searchParams named in config which are set. +export const pickSearchParams = ( + config: { key: string }[], + searchParams: URLSearchParams, +): Record => { + const params: Record = {}; + config.forEach(({ key }) => { + const value = searchParams.get(key); + if (value) { + params[key] = value; + } + }); + return params; +}; + +// omitSearchParams returns a copy of searchParams without the parameters named in config. +export const omitSearchParams = ( + config: { key: string }[], + searchParams: URLSearchParams, +): URLSearchParams => { + const next = new URLSearchParams(searchParams); + config.forEach(({ key }) => next.delete(key)); + return next; +};