mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 13:55:21 +02:00
feat(edge/stacks): increase status transparency [EE-5554] (#9094)
This commit is contained in:
parent
db61fb149b
commit
0bcb57568c
45 changed files with 1305 additions and 316 deletions
5
app/assets/ico/icon_up-to-date.svg
Normal file
5
app/assets/ico/icon_up-to-date.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" width="14" height="14" rx="7" fill="#039855"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4729 4.31086L6.29623 8.34169L5.1879 7.15753C4.98373 6.96503 4.6629 6.95336 4.42957 7.11669C4.20207 7.28586 4.1379 7.58336 4.2779 7.82253L5.5904 9.95753C5.71873 10.1559 5.9404 10.2784 6.19123 10.2784C6.4304 10.2784 6.6579 10.1559 6.78623 9.95753C6.99623 9.68336 11.0037 4.90586 11.0037 4.90586C11.5287 4.36919 10.8929 3.89669 10.4729 4.30503V4.31086Z" fill="#0BA5EC"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.1403 4.30503C11.0118 4.12405 10.7084 4.07605 10.4729 4.30503V4.31086L10.3899 4.39096L6.29623 8.34169L5.1879 7.15753C4.98373 6.96503 4.6629 6.95336 4.42957 7.11669C4.20207 7.28586 4.1379 7.58336 4.2779 7.82253L5.5904 9.95753C5.69222 10.1149 5.85279 10.2245 6.04006 10.2631C6.08883 10.2731 6.13941 10.2784 6.19123 10.2784C6.31863 10.2784 6.44272 10.2436 6.55028 10.1811C6.64463 10.1263 6.72626 10.0502 6.78623 9.95753C6.78962 9.9531 6.794 9.94751 6.79933 9.94078C6.85798 9.86677 7.03221 9.65571 7.27843 9.35943C8.07017 8.40673 9.60624 6.57293 10.4372 5.5816C10.7807 5.17172 11.0037 4.90586 11.0037 4.90586C11.0111 4.89829 11.0183 4.89074 11.0253 4.8832C11.0268 4.88147 11.0284 4.87975 11.03 4.87803C11.2318 4.65583 11.2369 4.44719 11.1444 4.31086C11.143 4.3089 11.1417 4.30696 11.1403 4.30503Z" fill="white"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
11
app/assets/ico/icon_updates-available.svg
Normal file
11
app/assets/ico/icon_updates-available.svg
Normal file
|
@ -0,0 +1,11 @@
|
|||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_0_118)">
|
||||
<rect x="0.5" width="14" height="14" rx="7" fill="#F79009"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.81723 4.55883C5.11013 4.26593 5.585 4.26593 5.87789 4.55883L7.55801 6.23895L9.23813 4.55883C9.53103 4.26593 10.0059 4.26593 10.2988 4.55883C10.5917 4.85172 10.5917 5.32659 10.2988 5.61949L8.61867 7.29961L10.2988 8.97973C10.5917 9.27262 10.5917 9.74749 10.2988 10.0404C10.0059 10.3333 9.53103 10.3333 9.23813 10.0404L7.55801 8.36027L5.87789 10.0404C5.585 10.3333 5.11013 10.3333 4.81723 10.0404C4.52434 9.74749 4.52434 9.27262 4.81723 8.97973L6.49735 7.29961L4.81723 5.61949C4.52434 5.32659 4.52434 4.85172 4.81723 4.55883Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_118">
|
||||
<rect x="0.5" width="14" height="14" rx="7" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 908 B |
|
@ -13,9 +13,11 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
|
|||
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
|
||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
|
||||
import { EdgeStackStatus } from '@/react/edge/edge-stacks/ListView/EdgeStackStatus';
|
||||
|
||||
export const componentsModule = angular
|
||||
.module('portainer.edge.react.components', [])
|
||||
.component('edgeStacksDatatableStatus', r2a(EdgeStackStatus, ['edgeStack']))
|
||||
.component(
|
||||
'edgeStackEnvironmentsDatatable',
|
||||
r2a(withUIRouter(withReactQuery(EnvironmentsDatatable)), [])
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import _ from 'lodash-es';
|
||||
import './datatable.css';
|
||||
import { ResourceControlOwnership as RCO } from '@/react/portainer/access-control/types';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
function isBetween(value, a, b) {
|
||||
return (value >= a && value <= b) || (value >= b && value <= a);
|
||||
|
@ -14,6 +15,7 @@ angular.module('portainer.app').controller('GenericDatatableController', [
|
|||
'PAGINATION_MAX_ITEMS',
|
||||
function ($interval, PaginationService, DatatableService, PAGINATION_MAX_ITEMS) {
|
||||
this.RCO = RCO;
|
||||
this.isBE = isBE;
|
||||
|
||||
this.state = {
|
||||
selectAll: false,
|
||||
|
|
|
@ -120,7 +120,7 @@ export const ngModule = angular
|
|||
'fallbackImage',
|
||||
r2a(FallbackImage, ['src', 'fallbackIcon', 'alt', 'size', 'className'])
|
||||
)
|
||||
.component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size']))
|
||||
.component('prIcon', r2a(Icon, ['className', 'icon', 'mode', 'size', 'spin']))
|
||||
.component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, []))
|
||||
.component(
|
||||
'dashboardItem',
|
||||
|
|
|
@ -29,15 +29,15 @@ interface Props {
|
|||
className?: string;
|
||||
size?: IconSize;
|
||||
mode?: IconMode;
|
||||
spin?: boolean;
|
||||
}
|
||||
|
||||
export function Icon({ icon, className, mode, size }: Props) {
|
||||
const classes = clsx(
|
||||
className,
|
||||
'icon inline-flex',
|
||||
{ [`icon-${mode}`]: mode },
|
||||
{ [`icon-${size}`]: size }
|
||||
);
|
||||
export function Icon({ icon, className, mode, size, spin }: Props) {
|
||||
const classes = clsx(className, 'icon inline-flex', {
|
||||
[`icon-${mode}`]: mode,
|
||||
[`icon-${size}`]: size,
|
||||
'animate-spin-slow': spin,
|
||||
});
|
||||
|
||||
if (typeof icon !== 'string') {
|
||||
const Icon = isValidElementType(icon) ? icon : null;
|
||||
|
|
|
@ -6,6 +6,7 @@ import { EdgeStackStatus, StatusType } from '@/react/edge/edge-stacks/types';
|
|||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { useParamState } from '@/react/hooks/useParamState';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
|
||||
|
@ -20,17 +21,29 @@ export function EnvironmentsDatatable() {
|
|||
const {
|
||||
params: { stackId },
|
||||
} = useCurrentStateAndParams();
|
||||
const edgeStackQuery = useEdgeStack(stackId);
|
||||
const edgeStackQuery = useEdgeStack(stackId, {
|
||||
refetchInterval(data) {
|
||||
if (!data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Object.values(data.Status).some((status) =>
|
||||
status.Status.every((s) => s.Type === StatusType.Running)
|
||||
)
|
||||
? 0
|
||||
: 10000;
|
||||
},
|
||||
});
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useParamState<StatusType>(
|
||||
'status',
|
||||
parseStatusFilter
|
||||
(value) => (value ? parseInt(value, 10) : undefined)
|
||||
);
|
||||
const tableState = useTableStateWithoutStorage('name');
|
||||
const endpointsQuery = useEnvironmentList({
|
||||
pageLimit: tableState.pageSize,
|
||||
page,
|
||||
page: page + 1,
|
||||
search: tableState.search,
|
||||
sort: tableState.sortBy.id as 'Group' | 'Name',
|
||||
order: tableState.sortBy.desc ? 'desc' : 'asc',
|
||||
|
@ -38,27 +51,32 @@ export function EnvironmentsDatatable() {
|
|||
edgeStackStatus: statusFilter,
|
||||
});
|
||||
|
||||
const currentFileVersion =
|
||||
edgeStackQuery.data?.StackFileVersion?.toString() || '';
|
||||
const gitConfigURL = edgeStackQuery.data?.GitConfig?.URL || '';
|
||||
const gitConfigCommitHash = edgeStackQuery.data?.GitConfig?.ConfigHash || '';
|
||||
const environments: Array<EdgeStackEnvironment> = useMemo(
|
||||
() =>
|
||||
endpointsQuery.environments.map((env) => ({
|
||||
...env,
|
||||
StackStatus:
|
||||
edgeStackQuery.data?.Status[env.Id] ||
|
||||
endpointsQuery.environments.map(
|
||||
(env) =>
|
||||
({
|
||||
Details: {
|
||||
Pending: true,
|
||||
Acknowledged: false,
|
||||
ImagesPulled: false,
|
||||
Error: false,
|
||||
Ok: false,
|
||||
RemoteUpdateSuccess: false,
|
||||
Remove: false,
|
||||
},
|
||||
EndpointID: env.Id,
|
||||
Error: '',
|
||||
} satisfies EdgeStackStatus),
|
||||
})),
|
||||
[edgeStackQuery.data?.Status, endpointsQuery.environments]
|
||||
...env,
|
||||
TargetFileVersion: currentFileVersion,
|
||||
GitConfigURL: gitConfigURL,
|
||||
TargetCommitHash: gitConfigCommitHash,
|
||||
StackStatus: getEnvStackStatus(
|
||||
env.Id,
|
||||
edgeStackQuery.data?.Status[env.Id]
|
||||
),
|
||||
} satisfies EdgeStackEnvironment)
|
||||
),
|
||||
[
|
||||
currentFileVersion,
|
||||
edgeStackQuery.data?.Status,
|
||||
endpointsQuery.environments,
|
||||
gitConfigCommitHash,
|
||||
gitConfigURL,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -81,11 +99,11 @@ export function EnvironmentsDatatable() {
|
|||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e || undefined)}
|
||||
options={[
|
||||
{ value: 'Pending', label: 'Pending' },
|
||||
{ value: 'Acknowledged', label: 'Acknowledged' },
|
||||
{ value: 'ImagesPulled', label: 'Images pre-pulled' },
|
||||
{ value: 'Ok', label: 'Deployed' },
|
||||
{ value: 'Error', label: 'Failed' },
|
||||
{ value: StatusType.Pending, label: 'Pending' },
|
||||
{ value: StatusType.Acknowledged, label: 'Acknowledged' },
|
||||
{ value: StatusType.ImagesPulled, label: 'Images pre-pulled' },
|
||||
{ value: StatusType.Running, label: 'Deployed' },
|
||||
{ value: StatusType.Error, label: 'Failed' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
@ -95,19 +113,31 @@ export function EnvironmentsDatatable() {
|
|||
);
|
||||
}
|
||||
|
||||
function parseStatusFilter(status: string | undefined): StatusType | undefined {
|
||||
switch (status) {
|
||||
case 'Pending':
|
||||
return 'Pending';
|
||||
case 'Acknowledged':
|
||||
return 'Acknowledged';
|
||||
case 'ImagesPulled':
|
||||
return 'ImagesPulled';
|
||||
case 'Ok':
|
||||
return 'Ok';
|
||||
case 'Error':
|
||||
return 'Error';
|
||||
default:
|
||||
return undefined;
|
||||
function getEnvStackStatus(
|
||||
envId: EnvironmentId,
|
||||
envStatus: EdgeStackStatus | undefined
|
||||
) {
|
||||
const pendingStatus = {
|
||||
Type: StatusType.Pending,
|
||||
Error: '',
|
||||
Time: new Date().valueOf() / 1000,
|
||||
};
|
||||
|
||||
let status = envStatus;
|
||||
if (!status) {
|
||||
status = {
|
||||
EndpointID: envId,
|
||||
DeploymentInfo: {
|
||||
ConfigHash: '',
|
||||
FileVersion: 0,
|
||||
},
|
||||
Status: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (status.Status.length === 0) {
|
||||
status.Status.push(pendingStatus);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
|
|
@ -2,13 +2,19 @@ import { CellContext, createColumnHelper } from '@tanstack/react-table';
|
|||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import UpdatesAvailable from '@/assets/ico/icon_updates-available.svg?c';
|
||||
import UpToDate from '@/assets/ico/icon_up-to-date.svg?c';
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { getDashboardRoute } from '@/react/portainer/environments/utils';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { EdgeStackStatus } from '../../types';
|
||||
import { DeploymentStatus, EdgeStackStatus, StatusType } from '../../types';
|
||||
|
||||
import { EnvironmentActions } from './EnvironmentActions';
|
||||
import { ActionStatus } from './ActionStatus';
|
||||
|
@ -16,20 +22,75 @@ import { EdgeStackEnvironment } from './types';
|
|||
|
||||
const columnHelper = createColumnHelper<EdgeStackEnvironment>();
|
||||
|
||||
export const columns = [
|
||||
export const columns = _.compact([
|
||||
columnHelper.accessor('Name', {
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
cell({ row: { original: env } }) {
|
||||
const { to, params } = getDashboardRoute(env);
|
||||
return (
|
||||
<Link to={to} params={params}>
|
||||
{env.Name}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((env) => endpointStatusLabel(env.StackStatus), {
|
||||
columnHelper.accessor((env) => endpointStatusLabel(env.StackStatus.Status), {
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell({ row: { original: env } }) {
|
||||
return (
|
||||
<ul className="list-none space-y-2">
|
||||
{env.StackStatus.Status.map((s) => (
|
||||
<li key={`status-${s.Type}-${s.Time}`}>
|
||||
<Status value={s.Type} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((env) => env.StackStatus.Error, {
|
||||
id: 'error',
|
||||
header: 'Error',
|
||||
cell: ErrorCell,
|
||||
columnHelper.accessor((env) => _.last(env.StackStatus.Status)?.Time, {
|
||||
id: 'statusDate',
|
||||
header: 'Time',
|
||||
cell({ row: { original: env } }) {
|
||||
return (
|
||||
<ul className="list-none space-y-2">
|
||||
{env.StackStatus.Status.map((s) => (
|
||||
<li key={`time-${s.Type}-${s.Time}`}>
|
||||
{isoDateFromTimestamp(s.Time)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
}),
|
||||
...(isBE
|
||||
? [
|
||||
columnHelper.accessor((env) => endpointTargetVersionLabel(env), {
|
||||
id: 'targetVersion',
|
||||
header: 'Target version',
|
||||
cell: TargetVersionCell,
|
||||
}),
|
||||
columnHelper.accessor(
|
||||
(env) => endpointDeployedVersionLabel(env.StackStatus),
|
||||
{
|
||||
id: 'deployedVersion',
|
||||
header: 'Deployed version',
|
||||
cell: DeployedVersionCell,
|
||||
}
|
||||
),
|
||||
]
|
||||
: []),
|
||||
columnHelper.accessor(
|
||||
(env) =>
|
||||
env.StackStatus.Status.find((s) => s.Type === StatusType.Error)?.Error,
|
||||
{
|
||||
id: 'error',
|
||||
header: 'Error',
|
||||
cell: ErrorCell,
|
||||
}
|
||||
),
|
||||
...(isBE
|
||||
? [
|
||||
columnHelper.display({
|
||||
|
@ -48,7 +109,7 @@ export const columns = [
|
|||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
]);
|
||||
|
||||
function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
@ -77,30 +138,151 @@ function ErrorCell({ getValue }: CellContext<EdgeStackEnvironment, string>) {
|
|||
);
|
||||
}
|
||||
|
||||
function endpointStatusLabel(status: EdgeStackStatus) {
|
||||
const details = (status && status.Details) || {};
|
||||
|
||||
function endpointStatusLabel(statusArray: Array<DeploymentStatus>) {
|
||||
const labels = [];
|
||||
|
||||
if (details.Acknowledged) {
|
||||
labels.push('Acknowledged');
|
||||
}
|
||||
|
||||
if (details.ImagesPulled) {
|
||||
labels.push('Images pre-pulled');
|
||||
}
|
||||
|
||||
if (details.Ok) {
|
||||
labels.push('Deployed');
|
||||
}
|
||||
|
||||
if (details.Error) {
|
||||
labels.push('Failed');
|
||||
}
|
||||
statusArray.forEach((status) => {
|
||||
if (status.Type === StatusType.Acknowledged) {
|
||||
labels.push('Acknowledged');
|
||||
}
|
||||
if (status.Type === StatusType.ImagesPulled) {
|
||||
labels.push('Images pre-pulled');
|
||||
}
|
||||
if (status.Type === StatusType.Running) {
|
||||
labels.push('Deployed');
|
||||
}
|
||||
if (status.Type === StatusType.Error) {
|
||||
labels.push('Failed');
|
||||
}
|
||||
});
|
||||
|
||||
if (!labels.length) {
|
||||
labels.push('Pending');
|
||||
}
|
||||
|
||||
return labels.join(', ');
|
||||
return _.uniq(labels).join(', ');
|
||||
}
|
||||
|
||||
function TargetVersionCell({
|
||||
row,
|
||||
getValue,
|
||||
}: CellContext<EdgeStackEnvironment, string>) {
|
||||
const value = getValue();
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{row.original.TargetCommitHash ? (
|
||||
<div>
|
||||
<a
|
||||
href={`${row.original.GitConfigURL}/commit/${row.original.TargetCommitHash}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div>{value}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function endpointTargetVersionLabel(env: EdgeStackEnvironment) {
|
||||
if (env.TargetCommitHash) {
|
||||
return env.TargetCommitHash.slice(0, 7).toString();
|
||||
}
|
||||
return env.TargetFileVersion.toString() || '';
|
||||
}
|
||||
|
||||
function DeployedVersionCell({
|
||||
row,
|
||||
getValue,
|
||||
}: CellContext<EdgeStackEnvironment, string>) {
|
||||
const value = getValue();
|
||||
if (!value || value === '0') {
|
||||
return (
|
||||
<div>
|
||||
<Icon icon={UpdatesAvailable} className="!mr-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let statusIcon = <Icon icon={UpToDate} className="!mr-2" />;
|
||||
if (
|
||||
(row.original.TargetCommitHash &&
|
||||
row.original.TargetCommitHash.slice(0, 7) !== value) ||
|
||||
(!row.original.TargetCommitHash && row.original.TargetFileVersion !== value)
|
||||
) {
|
||||
statusIcon = <Icon icon={UpdatesAvailable} className="!mr-2" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{row.original.TargetCommitHash ? (
|
||||
<div>
|
||||
{statusIcon}
|
||||
<a
|
||||
href={`${row.original.GitConfigURL}/commit/${row.original.TargetCommitHash}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{statusIcon}
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function endpointDeployedVersionLabel(status: EdgeStackStatus) {
|
||||
if (status.DeploymentInfo?.ConfigHash) {
|
||||
return status.DeploymentInfo?.ConfigHash.slice(0, 7).toString();
|
||||
}
|
||||
return status.DeploymentInfo?.FileVersion.toString() || '';
|
||||
}
|
||||
|
||||
function Status({ value }: { value: StatusType }) {
|
||||
const color = getStateColor(value);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={clsx('h-2 w-2 rounded-full', {
|
||||
'bg-orange-5': color === 'orange',
|
||||
'bg-green-5': color === 'green',
|
||||
'bg-error-5': color === 'red',
|
||||
})}
|
||||
/>
|
||||
|
||||
<span>{_.startCase(StatusType[value])}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStateColor(type: StatusType): 'orange' | 'green' | 'red' {
|
||||
switch (type) {
|
||||
case StatusType.Acknowledged:
|
||||
case StatusType.ImagesPulled:
|
||||
case StatusType.DeploymentReceived:
|
||||
case StatusType.Running:
|
||||
case StatusType.RemoteUpdateSuccess:
|
||||
case StatusType.Removed:
|
||||
return 'green';
|
||||
case StatusType.Error:
|
||||
return 'red';
|
||||
case StatusType.Pending:
|
||||
case StatusType.Deploying:
|
||||
case StatusType.Removing:
|
||||
default:
|
||||
return 'orange';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,4 +4,7 @@ import { EdgeStackStatus } from '../../types';
|
|||
|
||||
export type EdgeStackEnvironment = Environment & {
|
||||
StackStatus: EdgeStackStatus;
|
||||
TargetFileVersion: string;
|
||||
GitConfigURL: string;
|
||||
TargetCommitHash: string;
|
||||
};
|
||||
|
|
87
app/react/edge/edge-stacks/ListView/EdgeStackStatus.tsx
Normal file
87
app/react/edge/edge-stacks/ListView/EdgeStackStatus.tsx
Normal file
|
@ -0,0 +1,87 @@
|
|||
import _ from 'lodash';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
type Icon as IconType,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Icon, IconMode } from '@@/Icon';
|
||||
|
||||
import { DeploymentStatus, EdgeStack, StatusType } from '../types';
|
||||
|
||||
export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
|
||||
const status = Object.values(edgeStack.Status);
|
||||
const lastStatus = _.compact(status.map((s) => _.last(s.Status)));
|
||||
|
||||
const { icon, label, mode, spin } = getStatus(
|
||||
edgeStack.NumDeployments,
|
||||
lastStatus
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto inline-flex items-center gap-2">
|
||||
{icon && <Icon icon={icon} spin={spin} mode={mode} />}
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatus(
|
||||
numDeployments: number,
|
||||
envStatus: Array<DeploymentStatus>
|
||||
): {
|
||||
label: string;
|
||||
icon?: IconType;
|
||||
spin?: boolean;
|
||||
mode?: IconMode;
|
||||
} {
|
||||
if (envStatus.length < numDeployments) {
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
const allRunning = envStatus.every((s) => s.Type === StatusType.Running);
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
|
@ -38,10 +38,10 @@ export function DeploymentCounter({
|
|||
return (
|
||||
<span
|
||||
className={clsx(styles.root, {
|
||||
[styles.statusOk]: type === 'Ok',
|
||||
[styles.statusError]: type === 'Error',
|
||||
[styles.statusAcknowledged]: type === 'Acknowledged',
|
||||
[styles.statusImagesPulled]: type === 'ImagesPulled',
|
||||
[styles.statusOk]: type === StatusType.Running,
|
||||
[styles.statusError]: type === StatusType.Error,
|
||||
[styles.statusAcknowledged]: type === StatusType.Acknowledged,
|
||||
[styles.statusImagesPulled]: type === StatusType.ImagesPulled,
|
||||
[styles.statusTotal]: type === undefined,
|
||||
})}
|
||||
>
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Datatable } from '@@/datatables';
|
|||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { useEdgeStacks } from '../../queries/useEdgeStacks';
|
||||
import { EdgeStack } from '../../types';
|
||||
import { EdgeStack, StatusType } from '../../types';
|
||||
|
||||
import { createStore } from './store';
|
||||
import { columns } from './columns';
|
||||
|
@ -51,11 +51,16 @@ export function EdgeStacksDatatable() {
|
|||
|
||||
function aggregateStackStatus(stackStatus: EdgeStack['Status']) {
|
||||
const aggregateStatus = { ok: 0, error: 0, acknowledged: 0, imagesPulled: 0 };
|
||||
return Object.values(stackStatus).reduce((acc, envStatus) => {
|
||||
acc.ok += Number(envStatus.Details.Ok);
|
||||
acc.error += Number(envStatus.Details.Error);
|
||||
acc.acknowledged += Number(envStatus.Details.Acknowledged);
|
||||
acc.imagesPulled += Number(envStatus.Details.ImagesPulled);
|
||||
return acc;
|
||||
}, aggregateStatus);
|
||||
return Object.values(stackStatus).reduce(
|
||||
(acc, envStatus) =>
|
||||
envStatus.Status.reduce((acc, status) => {
|
||||
const { Type } = status;
|
||||
acc.ok += Number(Type === StatusType.Running);
|
||||
acc.error += Number(Type === StatusType.Error);
|
||||
acc.acknowledged += Number(Type === StatusType.Acknowledged);
|
||||
acc.imagesPulled += Number(Type === StatusType.ImagesPulled);
|
||||
return acc;
|
||||
}, acc),
|
||||
aggregateStatus
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
import _ from 'lodash';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
type Icon as IconType,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Icon, IconMode } from '@@/Icon';
|
||||
|
||||
import { DeploymentStatus, EdgeStack, StatusType } from '../../types';
|
||||
|
||||
export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
|
||||
const status = Object.values(edgeStack.Status);
|
||||
const lastStatus = _.compact(status.map((s) => _.last(s.Status)));
|
||||
|
||||
const { icon, label, mode, spin } = getStatus(
|
||||
edgeStack.NumDeployments,
|
||||
lastStatus
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto inline-flex items-center gap-2">
|
||||
{icon && <Icon icon={icon} spin={spin} mode={mode} />}
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatus(
|
||||
numDeployments: number,
|
||||
envStatus: Array<DeploymentStatus>
|
||||
): {
|
||||
label: string;
|
||||
icon?: IconType;
|
||||
spin?: boolean;
|
||||
mode?: IconMode;
|
||||
} {
|
||||
if (envStatus.length < numDeployments) {
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
const allRunning = envStatus.every((s) => s.Type === StatusType.Running);
|
||||
|
||||
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',
|
||||
};
|
||||
}
|
|
@ -6,6 +6,9 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
|||
|
||||
import { buildNameColumn } from '@@/datatables/NameCell';
|
||||
|
||||
import { StatusType } from '../../types';
|
||||
import { EdgeStackStatus } from '../EdgeStackStatus';
|
||||
|
||||
import { DecoratedEdgeStack } from './types';
|
||||
import { DeploymentCounter, DeploymentCounterLink } from './DeploymentCounter';
|
||||
|
||||
|
@ -25,7 +28,7 @@ export const columns = _.compact([
|
|||
cell: ({ getValue, row }) => (
|
||||
<DeploymentCounterLink
|
||||
count={getValue()}
|
||||
type="Acknowledged"
|
||||
type={StatusType.Acknowledged}
|
||||
stackId={row.original.Id}
|
||||
/>
|
||||
),
|
||||
|
@ -39,7 +42,7 @@ export const columns = _.compact([
|
|||
cell: ({ getValue, row }) => (
|
||||
<DeploymentCounterLink
|
||||
count={getValue()}
|
||||
type="ImagesPulled"
|
||||
type={StatusType.ImagesPulled}
|
||||
stackId={row.original.Id}
|
||||
/>
|
||||
),
|
||||
|
@ -54,7 +57,7 @@ export const columns = _.compact([
|
|||
cell: ({ getValue, row }) => (
|
||||
<DeploymentCounterLink
|
||||
count={getValue()}
|
||||
type="Ok"
|
||||
type={StatusType.Running}
|
||||
stackId={row.original.Id}
|
||||
/>
|
||||
),
|
||||
|
@ -69,7 +72,7 @@ export const columns = _.compact([
|
|||
cell: ({ getValue, row }) => (
|
||||
<DeploymentCounterLink
|
||||
count={getValue()}
|
||||
type="Error"
|
||||
type={StatusType.Error}
|
||||
stackId={row.original.Id}
|
||||
/>
|
||||
),
|
||||
|
@ -79,6 +82,19 @@ export const columns = _.compact([
|
|||
className: '[&>*]:justify-center',
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('Status', {
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<div className="w-full text-center">
|
||||
<EdgeStackStatus edgeStack={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
className: '[&>*]:justify-center',
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('NumDeployments', {
|
||||
header: 'Deployments',
|
||||
cell: ({ getValue }) => (
|
||||
|
|
|
@ -8,10 +8,24 @@ import { EdgeStack } from '../types';
|
|||
import { buildUrl } from './buildUrl';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useEdgeStack(id?: EdgeStack['Id']) {
|
||||
export function useEdgeStack(
|
||||
id?: EdgeStack['Id'],
|
||||
{
|
||||
refetchInterval,
|
||||
}: {
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
refetchInterval?:
|
||||
| number
|
||||
| false
|
||||
| ((data?: Awaited<ReturnType<typeof getEdgeStack>>) => false | number);
|
||||
} = {}
|
||||
) {
|
||||
return useQuery(id ? queryKeys.item(id) : [], () => getEdgeStack(id), {
|
||||
...withError('Failed loading Edge stack'),
|
||||
enabled: !!id,
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|||
import { EdgeStack } from '../types';
|
||||
|
||||
import { buildUrl } from './buildUrl';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useEdgeStacks<T = Array<EdgeStack>>({
|
||||
select,
|
||||
|
@ -19,7 +20,7 @@ export function useEdgeStacks<T = Array<EdgeStack>>({
|
|||
select?: (stacks: EdgeStack[]) => T;
|
||||
refetchInterval?: number | false | ((data?: T) => false | number);
|
||||
} = {}) {
|
||||
return useQuery(['edge_stacks'], () => getEdgeStacks(), {
|
||||
return useQuery(queryKeys.base(), () => getEdgeStacks(), {
|
||||
...withError('Failed loading Edge stack'),
|
||||
select,
|
||||
refetchInterval,
|
||||
|
|
|
@ -9,22 +9,44 @@ import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
|
|||
|
||||
import { EdgeGroup } from '../edge-groups/types';
|
||||
|
||||
interface EdgeStackStatusDetails {
|
||||
Pending: boolean;
|
||||
Ok: boolean;
|
||||
Error: boolean;
|
||||
Acknowledged: boolean;
|
||||
Remove: boolean;
|
||||
RemoteUpdateSuccess: boolean;
|
||||
ImagesPulled: boolean;
|
||||
export enum StatusType {
|
||||
/** Pending represents a pending edge stack */
|
||||
Pending,
|
||||
/** DeploymentReceived represents an edge environment which received the edge stack deployment */
|
||||
DeploymentReceived,
|
||||
/** Error represents an edge environment which failed to deploy its edge stack */
|
||||
Error,
|
||||
/** Acknowledged represents an acknowledged edge stack */
|
||||
Acknowledged,
|
||||
/** Removed represents a removed edge stack */
|
||||
Removed,
|
||||
/** StatusRemoteUpdateSuccess represents a successfully updated edge stack */
|
||||
RemoteUpdateSuccess,
|
||||
/** ImagesPulled represents a successfully images-pulling */
|
||||
ImagesPulled,
|
||||
/** Running represents a running Edge stack */
|
||||
Running,
|
||||
/** Deploying represents an Edge stack which is being deployed */
|
||||
Deploying,
|
||||
/** Removing represents an Edge stack which is being removed */
|
||||
Removing,
|
||||
}
|
||||
|
||||
export type StatusType = keyof EdgeStackStatusDetails;
|
||||
export interface DeploymentStatus {
|
||||
Type: StatusType;
|
||||
Error: string;
|
||||
Time: number;
|
||||
}
|
||||
|
||||
interface EdgeStackDeploymentInfo {
|
||||
FileVersion: number;
|
||||
ConfigHash: string;
|
||||
}
|
||||
|
||||
export interface EdgeStackStatus {
|
||||
Details: EdgeStackStatusDetails;
|
||||
Error: string;
|
||||
Status: Array<DeploymentStatus>;
|
||||
EndpointID: EnvironmentId;
|
||||
DeploymentInfo?: EdgeStackDeploymentInfo;
|
||||
}
|
||||
|
||||
export enum DeploymentType {
|
||||
|
|
16
app/react/edge/edge-stacks/utils/uniqueStatus.ts
Normal file
16
app/react/edge/edge-stacks/utils/uniqueStatus.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { DeploymentStatus } from '../types';
|
||||
|
||||
/**
|
||||
* returns the latest status object of each type
|
||||
*/
|
||||
export function uniqueStatus(statusArray: Array<DeploymentStatus> = []) {
|
||||
// keep only the last status object of each type, assume that the last status is the most recent
|
||||
return statusArray.reduce((acc, status) => {
|
||||
const index = acc.findIndex((s) => s.Type === status.Type);
|
||||
if (index === -1) {
|
||||
return [...acc, status];
|
||||
}
|
||||
|
||||
return [...acc.slice(0, index), ...acc.slice(index + 1), status];
|
||||
}, [] as Array<DeploymentStatus>);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue