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
|
// @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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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("")
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() ?? '',
|
||||||
});
|
});
|
||||||
|
|
|
@ -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() ?? '',
|
||||||
});
|
});
|
||||||
|
|
|
@ -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() ?? '',
|
||||||
});
|
});
|
||||||
|
|
|
@ -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() ?? '',
|
||||||
});
|
});
|
||||||
|
|
|
@ -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() ?? '',
|
||||||
});
|
});
|
||||||
|
|
|
@ -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() ?? '',
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,6 +7,6 @@ export const started = columnHelper.accessor(
|
||||||
{
|
{
|
||||||
header: 'Started',
|
header: 'Started',
|
||||||
id: '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' && (
|
{item.Status === 'Failed' && (
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
<TooltipWithChildren
|
<TooltipWithChildren
|
||||||
|
|
|
@ -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',
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) =>
|
||||||
[
|
[
|
||||||
|
|
|
@ -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`,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue