1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 13:29:41 +02:00

feat: add warning events count next to the status badge (#828)

This commit is contained in:
Steven Kang 2025-07-04 10:07:57 +12:00 committed by GitHub
parent f4df51884c
commit 1332f718ae
18 changed files with 120 additions and 37 deletions

View file

@ -22,6 +22,7 @@ import (
// @produce json // @produce json
// @param id path int true "Environment identifier" // @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 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" // @success 200 {array} portainer.K8sNamespaceInfo "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @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." // @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) 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) cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil { if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user") 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) 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 { if withResourceQuota {
return cli.CombineNamespacesWithResourceQuotas(namespaces, w) return cli.CombineNamespacesWithResourceQuotas(namespaces, w)
} }

View file

@ -351,6 +351,34 @@ func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace,
return namespace, nil 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 // 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 { func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError {
resourceQuotas, err := kcl.GetResourceQuotas("") resourceQuotas, err := kcl.GetResourceQuotas("")

View file

@ -621,15 +621,16 @@ type (
JobType int JobType int
K8sNamespaceInfo struct { K8sNamespaceInfo struct {
Id string `json:"Id"` Id string `json:"Id"`
Name string `json:"Name"` Name string `json:"Name"`
Status corev1.NamespaceStatus `json:"Status"` Status corev1.NamespaceStatus `json:"Status"`
Annotations map[string]string `json:"Annotations"` Annotations map[string]string `json:"Annotations"`
CreationDate string `json:"CreationDate"` CreationDate string `json:"CreationDate"`
NamespaceOwner string `json:"NamespaceOwner"` UnhealthyEventCount int `json:"UnhealthyEventCount"`
IsSystem bool `json:"IsSystem"` NamespaceOwner string `json:"NamespaceOwner"`
IsDefault bool `json:"IsDefault"` IsSystem bool `json:"IsSystem"`
ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"` IsDefault bool `json:"IsDefault"`
ResourceQuota *corev1.ResourceQuota `json:"ResourceQuota"`
} }
K8sNodeLimits struct { K8sNodeLimits struct {

View file

@ -100,18 +100,14 @@ function useSecretRowData(
): SecretRowData[] { ): SecretRowData[] {
return useMemo( return useMemo(
() => () =>
secrets?.map( (secrets ?? []).map((secret) => ({
(secret) => ...secret,
({ inUse: secret.IsUsed,
...secret, isSystem: namespaces
inUse: secret.IsUsed, ? namespaces.find((namespace) => namespace.Name === secret.Namespace)
isSystem: namespaces ?.IsSystem ?? false
? namespaces.find( : false,
(namespace) => namespace.Name === secret.Namespace })),
)?.IsSystem ?? false
: false,
}) ?? []
),
[secrets, namespaces] [secrets, namespaces]
); );
} }

View file

@ -3,5 +3,5 @@ import { columnHelper } from './helper';
export const command = columnHelper.accessor((row) => row.Command, { export const command = columnHelper.accessor((row) => row.Command, {
header: 'Command', header: 'Command',
id: 'command', id: 'command',
cell: ({ getValue }) => getValue(), cell: ({ getValue }) => getValue() ?? '',
}); });

View file

@ -3,5 +3,5 @@ import { columnHelper } from './helper';
export const schedule = columnHelper.accessor((row) => row.Schedule, { export const schedule = columnHelper.accessor((row) => row.Schedule, {
header: 'Schedule', header: 'Schedule',
id: 'schedule', id: 'schedule',
cell: ({ getValue }) => getValue(), cell: ({ getValue }) => getValue() ?? '',
}); });

View file

@ -3,5 +3,5 @@ import { columnHelper } from './helper';
export const timezone = columnHelper.accessor((row) => row.Timezone, { export const timezone = columnHelper.accessor((row) => row.Timezone, {
header: 'Timezone', header: 'Timezone',
id: 'timezone', id: 'timezone',
cell: ({ getValue }) => getValue(), cell: ({ getValue }) => getValue() ?? '',
}); });

View file

@ -3,5 +3,5 @@ import { columnHelper } from './helper';
export const command = columnHelper.accessor((row) => row.Command, { export const command = columnHelper.accessor((row) => row.Command, {
header: 'Command', header: 'Command',
id: 'command', id: 'command',
cell: ({ getValue }) => getValue(), cell: ({ getValue }) => getValue() ?? '',
}); });

View file

@ -3,5 +3,5 @@ import { columnHelper } from './helper';
export const duration = columnHelper.accessor((row) => row.Duration, { export const duration = columnHelper.accessor((row) => row.Duration, {
header: 'Duration', header: 'Duration',
id: 'duration', id: 'duration',
cell: ({ getValue }) => getValue(), cell: ({ getValue }) => getValue() ?? '',
}); });

View file

@ -3,5 +3,5 @@ import { columnHelper } from './helper';
export const finished = columnHelper.accessor((row) => row.FinishTime, { export const finished = columnHelper.accessor((row) => row.FinishTime, {
header: 'Finished', header: 'Finished',
id: 'finished', id: 'finished',
cell: ({ getValue }) => getValue(), cell: ({ getValue }) => getValue() ?? '',
}); });

View file

@ -7,6 +7,6 @@ export const started = columnHelper.accessor(
{ {
header: 'Started', header: 'Started',
id: 'started', id: 'started',
cell: ({ getValue }) => getValue(), cell: ({ getValue }) => getValue() ?? '',
} }
); );

View file

@ -26,7 +26,7 @@ function Cell({ row: { original: item } }: CellContext<Job, string>) {
}, },
])} ])}
/> />
{item.Status} {item.Status ?? ''}
{item.Status === 'Failed' && ( {item.Status === 'Failed' && (
<span className="ml-1"> <span className="ml-1">
<TooltipWithChildren <TooltipWithChildren

View file

@ -28,6 +28,7 @@ const tests: NamespaceTestData[] = [
Status: { Status: {
phase: 'Active', phase: 'Active',
}, },
UnhealthyEventCount: 0,
Annotations: null, Annotations: null,
CreationDate: '2024-10-17T17:50:08+13:00', CreationDate: '2024-10-17T17:50:08+13:00',
NamespaceOwner: 'admin', NamespaceOwner: 'admin',
@ -118,6 +119,7 @@ const tests: NamespaceTestData[] = [
Status: { Status: {
phase: 'Active', phase: 'Active',
}, },
UnhealthyEventCount: 0,
Annotations: { Annotations: {
asdf: 'asdf', asdf: 'asdf',
}, },

View file

@ -41,6 +41,7 @@ export function NamespacesDatatable() {
const namespacesQuery = useNamespacesQuery(environmentId, { const namespacesQuery = useNamespacesQuery(environmentId, {
autoRefreshRate: tableState.autoRefreshRate * 1000, autoRefreshRate: tableState.autoRefreshRate * 1000,
withResourceQuota: true, withResourceQuota: true,
withUnhealthyEvents: true,
}); });
const namespaces = Object.values(namespacesQuery.data ?? []); const namespaces = Object.values(namespacesQuery.data ?? []);
@ -181,6 +182,7 @@ function TableActions({
queryClient.setQueryData( queryClient.setQueryData(
queryKeys.list(environmentId, { queryKeys.list(environmentId, {
withResourceQuota: true, withResourceQuota: true,
withUnhealthyEvents: true,
}), }),
() => remainingNamespaces () => remainingNamespaces
); );

View file

@ -1,13 +1,17 @@
import _ from 'lodash'; import _ from 'lodash';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { AlertTriangle } from 'lucide-react';
import { isoDate } from '@/portainer/filters/filters'; import { isoDate } from '@/portainer/filters/filters';
import { useAuthorizations } from '@/react/hooks/useUser'; import { useAuthorizations } from '@/react/hooks/useUser';
import { pluralize } from '@/portainer/helpers/strings';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { StatusBadge } from '@@/StatusBadge'; import { StatusBadge } from '@@/StatusBadge';
import { Badge } from '@@/Badge'; import { Badge } from '@@/Badge';
import { SystemBadge } from '@@/Badge/SystemBadge'; import { SystemBadge } from '@@/Badge/SystemBadge';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { Icon } from '@@/Icon';
import { helper } from './helper'; import { helper } from './helper';
import { actions } from './actions'; import { actions } from './actions';
@ -45,12 +49,34 @@ export function useColumns() {
}), }),
helper.accessor('Status', { helper.accessor('Status', {
header: 'Status', header: 'Status',
cell({ getValue }) { cell({ getValue, row: { original: item } }) {
const status = getValue(); const status = getValue();
return ( return (
<StatusBadge color={getColor(status.phase)}> <div className="flex items-center gap-2">
{status.phase} <StatusBadge color={getColor(status.phase)}>
</StatusBadge> {status.phase}
</StatusBadge>
{item.UnhealthyEventCount > 0 && (
<TooltipWithChildren message="View events" position="top">
<span className="inline-flex">
<Link
to="kubernetes.resourcePools.resourcePool"
params={{ id: item.Name, tab: 'events' }}
data-cy={`namespace-warning-link-${item.Name}`}
>
<Badge type="warnSecondary">
<Icon
icon={AlertTriangle}
className="!mr-1 h-3 w-3"
/>
{item.UnhealthyEventCount}{' '}
{pluralize(item.UnhealthyEventCount, 'warning')}
</Badge>
</Link>
</span>
</TooltipWithChildren>
)}
</div>
); );
function getColor(status?: string) { function getColor(status?: string) {

View file

@ -5,7 +5,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
export const queryKeys = { export const queryKeys = {
list: ( list: (
environmentId: EnvironmentId, environmentId: EnvironmentId,
options?: { withResourceQuota?: boolean } options?: { withResourceQuota?: boolean; withUnhealthyEvents?: boolean }
) => ) =>
compact([ compact([
'environments', 'environments',
@ -13,6 +13,7 @@ export const queryKeys = {
'kubernetes', 'kubernetes',
'namespaces', 'namespaces',
options?.withResourceQuota, options?.withResourceQuota,
options?.withUnhealthyEvents,
]), ]),
namespace: (environmentId: EnvironmentId, namespace: string) => namespace: (environmentId: EnvironmentId, namespace: string) =>
[ [

View file

@ -13,14 +13,21 @@ export function useNamespacesQuery<T = PortainerNamespace[]>(
options?: { options?: {
autoRefreshRate?: number; autoRefreshRate?: number;
withResourceQuota?: boolean; withResourceQuota?: boolean;
withUnhealthyEvents?: boolean;
select?: (namespaces: PortainerNamespace[]) => T; select?: (namespaces: PortainerNamespace[]) => T;
} }
) { ) {
return useQuery( return useQuery(
queryKeys.list(environmentId, { queryKeys.list(environmentId, {
withResourceQuota: !!options?.withResourceQuota, withResourceQuota: !!options?.withResourceQuota,
withUnhealthyEvents: !!options?.withUnhealthyEvents,
}), }),
async () => getNamespaces(environmentId, options?.withResourceQuota), async () =>
getNamespaces(
environmentId,
options?.withResourceQuota,
options?.withUnhealthyEvents
),
{ {
...withGlobalError('Unable to get namespaces.'), ...withGlobalError('Unable to get namespaces.'),
refetchInterval() { refetchInterval() {
@ -34,9 +41,13 @@ export function useNamespacesQuery<T = PortainerNamespace[]>(
// getNamespaces is used to retrieve namespaces using the Portainer backend with caching // getNamespaces is used to retrieve namespaces using the Portainer backend with caching
export async function getNamespaces( export async function getNamespaces(
environmentId: EnvironmentId, environmentId: EnvironmentId,
withResourceQuota?: boolean withResourceQuota?: boolean,
withUnhealthyEvents?: boolean
) { ) {
const params = withResourceQuota ? { withResourceQuota } : {}; const params = {
withResourceQuota,
withUnhealthyEvents,
};
try { try {
const { data: namespaces } = await axios.get<PortainerNamespace[]>( const { data: namespaces } = await axios.get<PortainerNamespace[]>(
`kubernetes/${environmentId}/namespaces`, `kubernetes/${environmentId}/namespaces`,

View file

@ -10,6 +10,7 @@ export interface PortainerNamespace {
Id: string; Id: string;
Name: string; Name: string;
Status: NamespaceStatus; Status: NamespaceStatus;
UnhealthyEventCount: number;
Annotations: Record<string, string> | null; Annotations: Record<string, string> | null;
CreationDate: string; CreationDate: string;
NamespaceOwner: string; NamespaceOwner: string;