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; +};