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

feat(app/edge-stacks): summarize the edge stack statuses in the backend (#818)

This commit is contained in:
LP B 2025-07-01 15:04:10 +02:00 committed by GitHub
parent 363a62d885
commit e1c480d3c3
21 changed files with 645 additions and 312 deletions

View file

@ -5,7 +5,6 @@ import { useTableState } from '@@/datatables/useTableState';
import { getColumnVisibilityState } from '@@/datatables/ColumnVisibilityMenu';
import { useEdgeStacks } from '../../queries/useEdgeStacks';
import { EdgeStack, StatusType } from '../../types';
import { createStore } from './store';
import { columns } from './columns';
@ -20,11 +19,7 @@ const settingsStore = createStore(tableKey);
export function EdgeStacksDatatable() {
const tableState = useTableState(settingsStore, tableKey);
const edgeStacksQuery = useEdgeStacks<Array<DecoratedEdgeStack>>({
select: (edgeStacks) =>
edgeStacks.map((edgeStack) => ({
...edgeStack,
aggregatedStatus: aggregateStackStatus(edgeStack.Status),
})),
params: { summarizeStatuses: true },
refetchInterval: tableState.autoRefreshRate * 1000,
});
@ -50,16 +45,3 @@ export function EdgeStacksDatatable() {
/>
);
}
function aggregateStackStatus(stackStatus: EdgeStack['Status']) {
const aggregateStatus: Partial<Record<StatusType, number>> = {};
return Object.values(stackStatus).reduce(
(acc, envStatus) =>
envStatus.Status.reduce((acc, status) => {
const { Type } = status;
acc[Type] = (acc[Type] || 0) + 1;
return acc;
}, acc),
aggregateStatus
);
}

View file

@ -1,4 +1,3 @@
import _ from 'lodash';
import {
AlertTriangle,
CheckCircle,
@ -6,35 +5,22 @@ import {
Loader2,
XCircle,
MinusCircle,
PauseCircle,
} from 'lucide-react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isVersionSmaller } from '@/react/common/semver-utils';
import { Icon, IconMode } from '@@/Icon';
import { Tooltip } from '@@/Tip/Tooltip';
import { DeploymentStatus, EdgeStack, StatusType } from '../../types';
import { DecoratedEdgeStack, StatusSummary, SummarizedStatus } from './types';
export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
const status = Object.values(edgeStack.Status);
const lastStatus = _.compact(status.map((s) => _.last(s.Status)));
export function EdgeStackStatus({
edgeStack,
}: {
edgeStack: DecoratedEdgeStack;
}) {
const { StatusSummary } = edgeStack;
const environmentsQuery = useEnvironmentList({ edgeStackId: edgeStack.Id });
if (environmentsQuery.isLoading) {
return null;
}
const hasOldVersion = environmentsQuery.environments.some((env) =>
isVersionSmaller(env.Agent.Version, '2.19.0')
);
const { icon, label, mode, spin, tooltip } = getStatus(
edgeStack.NumDeployments,
lastStatus,
hasOldVersion
);
const { icon, label, mode, spin, tooltip } = getStatus(StatusSummary);
return (
<div className="mx-auto inline-flex items-center gap-2">
@ -45,106 +31,68 @@ export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
);
}
function getStatus(
numDeployments: number,
envStatus: Array<DeploymentStatus>,
hasOldVersion: boolean
): {
function getStatus(summary?: StatusSummary): {
label: string;
icon?: LucideIcon;
spin?: boolean;
mode?: IconMode;
tooltip?: string;
} {
if (!numDeployments || hasOldVersion) {
if (!summary) {
return {
label: 'Unavailable',
icon: MinusCircle,
mode: 'secondary',
tooltip: getUnavailableTooltip(),
tooltip: 'Status summary is unavailable',
};
}
const { Status, Reason } = summary;
if (!envStatus.length) {
return {
label: 'Deploying',
icon: Loader2,
spin: true,
mode: 'primary',
};
}
const allFailed = envStatus.every((s) => s.Type === StatusType.Error);
if (allFailed) {
return {
label: 'Failed',
icon: XCircle,
mode: 'danger',
};
}
if (envStatus.length < numDeployments) {
return {
label: 'Deploying',
icon: Loader2,
spin: true,
mode: 'primary',
};
}
const allCompleted = envStatus.every((s) => s.Type === StatusType.Completed);
if (allCompleted) {
return {
label: 'Completed',
icon: CheckCircle,
mode: 'success',
};
}
const allRunning = envStatus.every(
(s) =>
s.Type === StatusType.Running ||
(s.Type === StatusType.DeploymentReceived && hasOldVersion)
);
if (allRunning) {
return {
label: 'Running',
icon: CheckCircle,
mode: 'success',
};
}
const hasDeploying = envStatus.some((s) => s.Type === StatusType.Deploying);
const hasRunning = envStatus.some((s) => s.Type === StatusType.Running);
const hasFailed = envStatus.some((s) => s.Type === StatusType.Error);
if (hasRunning && hasFailed && !hasDeploying) {
return {
label: 'Partially Running',
icon: AlertTriangle,
mode: 'warning',
};
}
return {
label: 'Deploying',
icon: Loader2,
spin: true,
mode: 'primary',
};
function getUnavailableTooltip() {
if (!numDeployments) {
return 'Your edge stack is currently unavailable due to the absence of an available environment in your edge group';
}
if (hasOldVersion) {
return 'Please note that the new status feature for the Edge stack is only available for Edge Agent versions 2.19.0 and above. To access the status of your edge stack, it is essential to upgrade your Edge Agent to a corresponding version that is compatible with your Portainer server.';
}
return '';
switch (Status) {
case SummarizedStatus.Deploying:
return {
label: 'Deploying',
icon: Loader2,
spin: true,
mode: 'primary',
};
case SummarizedStatus.Failed:
return {
label: 'Failed',
icon: XCircle,
mode: 'danger',
};
case SummarizedStatus.Paused:
return {
label: 'Paused',
icon: PauseCircle,
mode: 'warning',
};
case SummarizedStatus.PartiallyRunning:
return {
label: 'Partially Running',
icon: AlertTriangle,
mode: 'warning',
};
case SummarizedStatus.Completed:
return {
label: 'Completed',
icon: CheckCircle,
mode: 'success',
};
case SummarizedStatus.Running:
return {
label: 'Running',
icon: CheckCircle,
mode: 'success',
};
case SummarizedStatus.Unavailable:
default:
return {
label: 'Unavailable',
icon: MinusCircle,
mode: 'secondary',
tooltip: Reason,
};
}
}

View file

@ -5,8 +5,9 @@ import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { GitCommitLink } from '@/react/portainer/gitops/GitCommitLink';
import { buildNameColumn } from '@@/datatables/buildNameColumn';
import { buildNameColumnFromObject } from '@@/datatables/buildNameColumn';
import { Link } from '@@/Link';
import { Tooltip } from '@@/Tip/Tooltip';
import { StatusType } from '../../types';
@ -17,14 +18,15 @@ import { DeploymentCounter } from './DeploymentCounter';
const columnHelper = createColumnHelper<DecoratedEdgeStack>();
export const columns = _.compact([
buildNameColumn<DecoratedEdgeStack>(
'Name',
'edge.stacks.edit',
'edge-stacks-name',
'stackId'
),
buildNameColumnFromObject<DecoratedEdgeStack>({
nameKey: 'Name',
path: 'edge.stacks.edit',
dataCy: 'edge-stacks-name',
idParam: 'stackId',
}),
columnHelper.accessor(
(item) => item.aggregatedStatus[StatusType.Acknowledged] || 0,
(item) =>
item.StatusSummary?.AggregatedStatus?.[StatusType.Acknowledged] || 0,
{
header: 'Acknowledged',
enableSorting: false,
@ -43,7 +45,8 @@ export const columns = _.compact([
),
isBE &&
columnHelper.accessor(
(item) => item.aggregatedStatus[StatusType.ImagesPulled] || 0,
(item) =>
item.StatusSummary?.AggregatedStatus?.[StatusType.ImagesPulled] || 0,
{
header: 'Images pre-pulled',
cell: ({ getValue, row: { original: item } }) => {
@ -67,7 +70,9 @@ export const columns = _.compact([
}
),
columnHelper.accessor(
(item) => item.aggregatedStatus[StatusType.DeploymentReceived] || 0,
(item) =>
item.StatusSummary?.AggregatedStatus?.[StatusType.DeploymentReceived] ||
0,
{
header: 'Deployments received',
cell: ({ getValue, row }) => (
@ -85,7 +90,7 @@ export const columns = _.compact([
}
),
columnHelper.accessor(
(item) => item.aggregatedStatus[StatusType.Error] || 0,
(item) => item.StatusSummary?.AggregatedStatus?.[StatusType.Error] || 0,
{
header: 'Deployments failed',
cell: ({ getValue, row }) => {
@ -123,7 +128,7 @@ export const columns = _.compact([
}
),
columnHelper.accessor('Status', {
header: 'Status',
header: StatusHeader,
cell: ({ row }) => (
<div className="w-full text-center">
<EdgeStackStatus edgeStack={row.original} />
@ -167,3 +172,27 @@ export const columns = _.compact([
}
),
]);
function StatusHeader() {
return (
<>
Status
<Tooltip
position="top"
message={
<>
<div>
The status feature for the Edge stack is only available for Edge
Agent versions 2.19.0 and above.
</div>
<div>
To access the status of your edge stack, it is essential to
upgrade your Edge Agent to a corresponding version that is
compatible with your Portainer server.
</div>
</>
}
/>
</>
);
}

View file

@ -1,5 +1,21 @@
import { EdgeStack, StatusType } from '../../types';
export type DecoratedEdgeStack = EdgeStack & {
aggregatedStatus: Partial<Record<StatusType, number>>;
export enum SummarizedStatus {
Unavailable = 'Unavailable',
Deploying = 'Deploying',
Failed = 'Failed',
Paused = 'Paused',
PartiallyRunning = 'PartiallyRunning',
Completed = 'Completed',
Running = 'Running',
}
export type StatusSummary = {
AggregatedStatus?: Partial<Record<StatusType, number>>;
Status: SummarizedStatus;
Reason: string;
};
export type DecoratedEdgeStack = EdgeStack & {
StatusSummary?: StatusSummary;
};

View file

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { withError } from '@/react-tools/react-query';
import { withGlobalError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EdgeStack } from '../types';
@ -8,28 +8,30 @@ import { EdgeStack } from '../types';
import { buildUrl } from './buildUrl';
import { queryKeys } from './query-keys';
export function useEdgeStacks<T = Array<EdgeStack>>({
select,
/**
* If set to a number, the query will continuously refetch at this frequency in milliseconds.
* If set to a function, the function will be executed with the latest data and query to compute a frequency
* Defaults to `false`.
*/
type QueryParams = {
summarizeStatuses?: boolean;
};
export function useEdgeStacks<T extends EdgeStack[] = EdgeStack[]>({
params,
refetchInterval,
}: {
select?: (stacks: EdgeStack[]) => T;
params?: QueryParams;
refetchInterval?: number | false | ((data?: T) => false | number);
} = {}) {
return useQuery(queryKeys.base(), () => getEdgeStacks(), {
...withError('Failed loading Edge stack'),
select,
return useQuery({
queryKey: queryKeys.base(),
queryFn: () => getEdgeStacks<T>(params),
refetchInterval,
...withGlobalError('Failed loading Edge stack'),
});
}
export async function getEdgeStacks() {
async function getEdgeStacks<T extends EdgeStack[] = EdgeStack[]>(
params: QueryParams = {}
) {
try {
const { data } = await axios.get<EdgeStack[]>(buildUrl());
const { data } = await axios.get<T>(buildUrl(), { params });
return data;
} catch (e) {
throw parseAxiosError(e as Error);