From 1332f718aecb7f51fa26566a0f03fe47aca58240 Mon Sep 17 00:00:00 2001 From: Steven Kang Date: Fri, 4 Jul 2025 10:07:57 +1200 Subject: [PATCH] feat: add warning events count next to the status badge (#828) --- api/http/handler/kubernetes/namespaces.go | 15 ++++++++ api/kubernetes/cli/namespace.go | 28 +++++++++++++++ api/portainer.go | 19 ++++++----- .../SecretsDatatable/SecretsDatatable.tsx | 20 +++++------ .../CronJobsDatatable/columns/command.tsx | 2 +- .../CronJobsDatatable/columns/schedule.tsx | 2 +- .../CronJobsDatatable/columns/timezone.tsx | 2 +- .../JobsDatatable/columns/command.tsx | 2 +- .../JobsDatatable/columns/duration.tsx | 2 +- .../JobsDatatable/columns/finished.tsx | 2 +- .../JobsDatatable/columns/started.tsx | 2 +- .../JobsView/JobsDatatable/columns/status.tsx | 2 +- .../ItemView/useNamespaceFormValues.test.ts | 2 ++ .../ListView/NamespacesDatatable.tsx | 2 ++ .../ListView/columns/useColumns.tsx | 34 ++++++++++++++++--- .../namespaces/queries/queryKeys.ts | 3 +- .../namespaces/queries/useNamespacesQuery.ts | 17 ++++++++-- app/react/kubernetes/namespaces/types.ts | 1 + 18 files changed, 120 insertions(+), 37 deletions(-) diff --git a/api/http/handler/kubernetes/namespaces.go b/api/http/handler/kubernetes/namespaces.go index 2efde3b85..75dae9e69 100644 --- a/api/http/handler/kubernetes/namespaces.go +++ b/api/http/handler/kubernetes/namespaces.go @@ -22,6 +22,7 @@ import ( // @produce json // @param id path int true "Environment identifier" // @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false" +// @param withUnhealthyEvents query boolean true "When set to true, include the unhealthy events information as part of the Namespace information. Default is false" // @success 200 {array} portainer.K8sNamespaceInfo "Success" // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." @@ -36,6 +37,12 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err) } + withUnhealthyEvents, err := request.RetrieveBooleanQueryParameter(r, "withUnhealthyEvents", true) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Invalid query parameter withUnhealthyEvents") + return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withUnhealthyEvents. Error: ", err) + } + cli, httpErr := handler.prepareKubeClient(r) if httpErr != nil { log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user") @@ -48,6 +55,14 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to retrieve namespaces from the Kubernetes cluster. Error: ", err) } + if withUnhealthyEvents { + namespaces, err = cli.CombineNamespacesWithUnhealthyEvents(namespaces) + if err != nil { + log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Unable to combine namespaces with unhealthy events") + return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to combine namespaces with unhealthy events. Error: ", err) + } + } + if withResourceQuota { return cli.CombineNamespacesWithResourceQuotas(namespaces, w) } diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go index 11307d651..bb29680b5 100644 --- a/api/kubernetes/cli/namespace.go +++ b/api/kubernetes/cli/namespace.go @@ -351,6 +351,34 @@ func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace, return namespace, nil } +// CombineNamespacesWithUnhealthyEvents combines namespaces with unhealthy events across all namespaces +func (kcl *KubeClient) CombineNamespacesWithUnhealthyEvents(namespaces map[string]portainer.K8sNamespaceInfo) (map[string]portainer.K8sNamespaceInfo, error) { + allEvents, err := kcl.GetEvents("", "") + if err != nil && !k8serrors.IsNotFound(err) { + log.Error(). + Str("context", "CombineNamespacesWithUnhealthyEvents"). + Err(err). + Msg("unable to retrieve unhealthy events from the Kubernetes for an admin user") + return nil, err + } + + unhealthyEventCounts := make(map[string]int) + for _, event := range allEvents { + if event.Type == "Warning" { + unhealthyEventCounts[event.Namespace]++ + } + } + + for namespaceName, namespace := range namespaces { + if count, exists := unhealthyEventCounts[namespaceName]; exists { + namespace.UnhealthyEventCount = count + namespaces[namespaceName] = namespace + } + } + + return namespaces, nil +} + // CombineNamespacesWithResourceQuotas combines namespaces with resource quotas where matching is based on "portainer-rq-"+namespace.Name func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError { resourceQuotas, err := kcl.GetResourceQuotas("") diff --git a/api/portainer.go b/api/portainer.go index 1e5f2ea6f..bdd52aa34 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -621,15 +621,16 @@ type ( JobType int K8sNamespaceInfo struct { - Id string `json:"Id"` - Name string `json:"Name"` - Status corev1.NamespaceStatus `json:"Status"` - Annotations map[string]string `json:"Annotations"` - CreationDate string `json:"CreationDate"` - NamespaceOwner string `json:"NamespaceOwner"` - IsSystem bool `json:"IsSystem"` - IsDefault bool `json:"IsDefault"` - ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"` + Id string `json:"Id"` + Name string `json:"Name"` + Status corev1.NamespaceStatus `json:"Status"` + Annotations map[string]string `json:"Annotations"` + CreationDate string `json:"CreationDate"` + UnhealthyEventCount int `json:"UnhealthyEventCount"` + NamespaceOwner string `json:"NamespaceOwner"` + IsSystem bool `json:"IsSystem"` + IsDefault bool `json:"IsDefault"` + ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"` } K8sNodeLimits struct { diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx index 8cbc6de59..4f0140d55 100644 --- a/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx +++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx @@ -100,18 +100,14 @@ function useSecretRowData( ): SecretRowData[] { return useMemo( () => - secrets?.map( - (secret) => - ({ - ...secret, - inUse: secret.IsUsed, - isSystem: namespaces - ? namespaces.find( - (namespace) => namespace.Name === secret.Namespace - )?.IsSystem ?? false - : false, - }) ?? [] - ), + (secrets ?? []).map((secret) => ({ + ...secret, + inUse: secret.IsUsed, + isSystem: namespaces + ? namespaces.find((namespace) => namespace.Name === secret.Namespace) + ?.IsSystem ?? false + : false, + })), [secrets, namespaces] ); } diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx index 04e6e155f..2d1977a0a 100644 --- a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/command.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const command = columnHelper.accessor((row) => row.Command, { header: 'Command', id: 'command', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx index 60a673eb0..f979240e8 100644 --- a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/schedule.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const schedule = columnHelper.accessor((row) => row.Schedule, { header: 'Schedule', id: 'schedule', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx index 54bb35a79..3b262b93b 100644 --- a/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx +++ b/app/react/kubernetes/more-resources/JobsView/CronJobsDatatable/columns/timezone.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const timezone = columnHelper.accessor((row) => row.Timezone, { header: 'Timezone', id: 'timezone', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx index 04e6e155f..2d1977a0a 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/command.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const command = columnHelper.accessor((row) => row.Command, { header: 'Command', id: 'command', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx index 23cfcaf34..b189d5a7a 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/duration.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const duration = columnHelper.accessor((row) => row.Duration, { header: 'Duration', id: 'duration', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx index b3bcb4e0f..cf8e5b5e3 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/finished.tsx @@ -3,5 +3,5 @@ import { columnHelper } from './helper'; export const finished = columnHelper.accessor((row) => row.FinishTime, { header: 'Finished', id: 'finished', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', }); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx index eff7f2465..8ea383c64 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/started.tsx @@ -7,6 +7,6 @@ export const started = columnHelper.accessor( { header: 'Started', id: 'started', - cell: ({ getValue }) => getValue(), + cell: ({ getValue }) => getValue() ?? '', } ); diff --git a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx index 2f6ad1768..93ecb66a2 100644 --- a/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx +++ b/app/react/kubernetes/more-resources/JobsView/JobsDatatable/columns/status.tsx @@ -26,7 +26,7 @@ function Cell({ row: { original: item } }: CellContext) { }, ])} /> - {item.Status} + {item.Status ?? ''} {item.Status === 'Failed' && ( remainingNamespaces ); diff --git a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx index 541c2315f..ba5eaf159 100644 --- a/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx +++ b/app/react/kubernetes/namespaces/ListView/columns/useColumns.tsx @@ -1,13 +1,17 @@ import _ from 'lodash'; import { useMemo } from 'react'; +import { AlertTriangle } from 'lucide-react'; import { isoDate } from '@/portainer/filters/filters'; import { useAuthorizations } from '@/react/hooks/useUser'; +import { pluralize } from '@/portainer/helpers/strings'; import { Link } from '@@/Link'; import { StatusBadge } from '@@/StatusBadge'; import { Badge } from '@@/Badge'; import { SystemBadge } from '@@/Badge/SystemBadge'; +import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; +import { Icon } from '@@/Icon'; import { helper } from './helper'; import { actions } from './actions'; @@ -45,12 +49,34 @@ export function useColumns() { }), helper.accessor('Status', { header: 'Status', - cell({ getValue }) { + cell({ getValue, row: { original: item } }) { const status = getValue(); return ( - - {status.phase} - +
+ + {status.phase} + + {item.UnhealthyEventCount > 0 && ( + + + + + + {item.UnhealthyEventCount}{' '} + {pluralize(item.UnhealthyEventCount, 'warning')} + + + + + )} +
); function getColor(status?: string) { diff --git a/app/react/kubernetes/namespaces/queries/queryKeys.ts b/app/react/kubernetes/namespaces/queries/queryKeys.ts index ecfe4ea58..6c0a04676 100644 --- a/app/react/kubernetes/namespaces/queries/queryKeys.ts +++ b/app/react/kubernetes/namespaces/queries/queryKeys.ts @@ -5,7 +5,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; export const queryKeys = { list: ( environmentId: EnvironmentId, - options?: { withResourceQuota?: boolean } + options?: { withResourceQuota?: boolean; withUnhealthyEvents?: boolean } ) => compact([ 'environments', @@ -13,6 +13,7 @@ export const queryKeys = { 'kubernetes', 'namespaces', options?.withResourceQuota, + options?.withUnhealthyEvents, ]), namespace: (environmentId: EnvironmentId, namespace: string) => [ diff --git a/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts index b4cd2d712..e7977db84 100644 --- a/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts +++ b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts @@ -13,14 +13,21 @@ export function useNamespacesQuery( options?: { autoRefreshRate?: number; withResourceQuota?: boolean; + withUnhealthyEvents?: boolean; select?: (namespaces: PortainerNamespace[]) => T; } ) { return useQuery( queryKeys.list(environmentId, { withResourceQuota: !!options?.withResourceQuota, + withUnhealthyEvents: !!options?.withUnhealthyEvents, }), - async () => getNamespaces(environmentId, options?.withResourceQuota), + async () => + getNamespaces( + environmentId, + options?.withResourceQuota, + options?.withUnhealthyEvents + ), { ...withGlobalError('Unable to get namespaces.'), refetchInterval() { @@ -34,9 +41,13 @@ export function useNamespacesQuery( // getNamespaces is used to retrieve namespaces using the Portainer backend with caching export async function getNamespaces( environmentId: EnvironmentId, - withResourceQuota?: boolean + withResourceQuota?: boolean, + withUnhealthyEvents?: boolean ) { - const params = withResourceQuota ? { withResourceQuota } : {}; + const params = { + withResourceQuota, + withUnhealthyEvents, + }; try { const { data: namespaces } = await axios.get( `kubernetes/${environmentId}/namespaces`, diff --git a/app/react/kubernetes/namespaces/types.ts b/app/react/kubernetes/namespaces/types.ts index ba4abb744..f3626e675 100644 --- a/app/react/kubernetes/namespaces/types.ts +++ b/app/react/kubernetes/namespaces/types.ts @@ -10,6 +10,7 @@ export interface PortainerNamespace { Id: string; Name: string; Status: NamespaceStatus; + UnhealthyEventCount: number; Annotations: Record | null; CreationDate: string; NamespaceOwner: string;