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
// @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)
}

View file

@ -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("")

View file

@ -626,6 +626,7 @@ type (
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"`

View file

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

View file

@ -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() ?? '',
});

View file

@ -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() ?? '',
});

View file

@ -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() ?? '',
});

View file

@ -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() ?? '',
});

View file

@ -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() ?? '',
});

View file

@ -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() ?? '',
});

View file

@ -7,6 +7,6 @@ export const started = columnHelper.accessor(
{
header: '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' && (
<span className="ml-1">
<TooltipWithChildren

View file

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

View file

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

View file

@ -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 (
<div className="flex items-center gap-2">
<StatusBadge color={getColor(status.phase)}>
{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) {

View file

@ -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) =>
[

View file

@ -13,14 +13,21 @@ export function useNamespacesQuery<T = PortainerNamespace[]>(
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<T = PortainerNamespace[]>(
// 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<PortainerNamespace[]>(
`kubernetes/${environmentId}/namespaces`,

View file

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