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:
parent
f4df51884c
commit
1332f718ae
18 changed files with 120 additions and 37 deletions
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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("")
|
||||
|
|
|
@ -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"`
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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() ?? '',
|
||||
});
|
||||
|
|
|
@ -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() ?? '',
|
||||
});
|
||||
|
|
|
@ -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() ?? '',
|
||||
});
|
||||
|
|
|
@ -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() ?? '',
|
||||
});
|
||||
|
|
|
@ -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() ?? '',
|
||||
});
|
||||
|
|
|
@ -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() ?? '',
|
||||
});
|
||||
|
|
|
@ -7,6 +7,6 @@ export const started = columnHelper.accessor(
|
|||
{
|
||||
header: 'Started',
|
||||
id: 'started',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
cell: ({ getValue }) => getValue() ?? '',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -26,7 +26,7 @@ function Cell({ row: { original: item } }: CellContext<Job, string>) {
|
|||
},
|
||||
])}
|
||||
/>
|
||||
{item.Status}
|
||||
{item.Status ?? ''}
|
||||
{item.Status === 'Failed' && (
|
||||
<span className="ml-1">
|
||||
<TooltipWithChildren
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) =>
|
||||
[
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -10,6 +10,7 @@ export interface PortainerNamespace {
|
|||
Id: string;
|
||||
Name: string;
|
||||
Status: NamespaceStatus;
|
||||
UnhealthyEventCount: number;
|
||||
Annotations: Record<string, string> | null;
|
||||
CreationDate: string;
|
||||
NamespaceOwner: string;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue