1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-25 00:09:40 +02:00

refactor(edge/stacks): migrate list view to react [EE-2237] (#9186)

This commit is contained in:
Chaim Lev-Ari 2023-07-12 18:26:52 +04:00 committed by GitHub
parent 020ecb740a
commit a216a1e960
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 482 additions and 298 deletions

View file

@ -0,0 +1,28 @@
.root {
padding: 2px 10px;
border-radius: 10px;
}
.status-acknowledged {
color: #337ab7;
background-color: rgba(51, 122, 183, 0.1);
}
.status-images-pulled {
color: #e1a800;
background-color: rgba(238, 192, 32, 0.1);
}
.status-ok {
color: #23ae89;
background-color: rgba(35, 174, 137, 0.1);
}
.status-error {
color: #ae2323;
background-color: rgba(174, 35, 35, 0.1);
}
.status-total {
background-color: rgba(168, 167, 167, 0.1);
}

View file

@ -0,0 +1,51 @@
import clsx from 'clsx';
import { Link } from '@@/Link';
import { EdgeStack, StatusType } from '../../types';
import styles from './DeploymentCounter.module.css';
export function DeploymentCounterLink({
count,
type,
stackId,
}: {
count: number;
type: StatusType;
stackId: EdgeStack['Id'];
}) {
return (
<div className="text-center">
<Link
className="hover:no-underline"
to="edge.stacks.edit"
params={{ stackId, tab: 1, status: type }}
>
<DeploymentCounter count={count} type={type} />
</Link>
</div>
);
}
export function DeploymentCounter({
count,
type,
}: {
count: number;
type?: StatusType;
}) {
return (
<span
className={clsx(styles.root, {
[styles.statusOk]: type === 'Ok',
[styles.statusError]: type === 'Error',
[styles.statusAcknowledged]: type === 'Acknowledged',
[styles.statusImagesPulled]: type === 'ImagesPulled',
[styles.statusTotal]: type === undefined,
})}
>
&bull; {count}
</span>
);
}

View file

@ -0,0 +1,61 @@
import { Layers } from 'lucide-react';
import { Datatable } from '@@/datatables';
import { useTableState } from '@@/datatables/useTableState';
import { useEdgeStacks } from '../../queries/useEdgeStacks';
import { EdgeStack } from '../../types';
import { createStore } from './store';
import { columns } from './columns';
import { DecoratedEdgeStack } from './types';
import { TableSettingsMenus } from './TableSettingsMenus';
import { TableActions } from './TableActions';
const tableKey = 'edge-stacks';
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),
})),
refetchInterval: tableState.autoRefreshRate * 1000,
});
return (
<Datatable
title="Edge Stacks"
titleIcon={Layers}
columns={columns}
dataset={edgeStacksQuery.data || []}
settingsManager={tableState}
emptyContentLabel="No stack available."
isLoading={edgeStacksQuery.isLoading}
renderTableSettings={(tableInstance) => (
<TableSettingsMenus
tableInstance={tableInstance}
tableState={tableState}
/>
)}
renderTableActions={(selectedItems) => (
<TableActions selectedItems={selectedItems} />
)}
/>
);
}
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);
}

View file

@ -0,0 +1,62 @@
import { Trash2, Plus } from 'lucide-react';
import { notifySuccess } from '@/portainer/services/notifications';
import { Button } from '@@/buttons';
import { confirmDestructive } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { Link } from '@@/Link';
import { useDeleteEdgeStacksMutation } from './useDeleteEdgeStacksMutation';
import { DecoratedEdgeStack } from './types';
export function TableActions({
selectedItems,
}: {
selectedItems: Array<DecoratedEdgeStack>;
}) {
const removeMutation = useDeleteEdgeStacksMutation();
return (
<div className="flex items-center gap-2">
<Button
color="dangerlight"
disabled={selectedItems.length === 0}
onClick={() => handleRemove(selectedItems)}
icon={Trash2}
className="!m-0"
>
Remove
</Button>
<Button
as={Link}
props={{ to: 'edge.stacks.new' }}
icon={Plus}
className="!m-0"
data-cy="edgeStack-addStackButton"
>
Add stack
</Button>
</div>
);
async function handleRemove(selectedItems: Array<DecoratedEdgeStack>) {
const confirmed = await confirmDestructive({
title: 'Are you sure?',
message: 'Are you sure you want to remove the selected Edge stack(s)?',
confirmButton: buildConfirmButton('Remove', 'danger'),
});
if (!confirmed) {
return;
}
const ids = selectedItems.map((item) => item.Id);
removeMutation.mutate(ids, {
onSuccess: () => {
notifySuccess('Success', 'Edge stack(s) removed');
},
});
}
}

View file

@ -0,0 +1,41 @@
import { Table } from '@tanstack/react-table';
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
import { TableSettingsMenu } from '@@/datatables';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { DecoratedEdgeStack } from './types';
import { TableSettings } from './store';
export function TableSettingsMenus({
tableInstance,
tableState,
}: {
tableInstance: Table<DecoratedEdgeStack>;
tableState: TableSettings;
}) {
const columnsToHide = tableInstance
.getAllColumns()
.filter((col) => col.getCanHide());
return (
<>
<ColumnVisibilityMenu<DecoratedEdgeStack>
columns={columnsToHide}
onChange={(hiddenColumns) => {
tableState.setHiddenColumns(hiddenColumns);
tableInstance.setColumnVisibility(
Object.fromEntries(hiddenColumns.map((col) => [col, false]))
);
}}
value={tableState.hiddenColumns}
/>
<TableSettingsMenu>
<TableSettingsMenuAutoRefresh
value={tableState.autoRefreshRate}
onChange={(value) => tableState.setAutoRefreshRate(value)}
/>
</TableSettingsMenu>
</>
);
}

View file

@ -0,0 +1,129 @@
import { createColumnHelper } from '@tanstack/react-table';
import _ from 'lodash';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { buildNameColumn } from '@@/datatables/NameCell';
import { DecoratedEdgeStack } from './types';
import { DeploymentCounter, DeploymentCounterLink } from './DeploymentCounter';
const columnHelper = createColumnHelper<DecoratedEdgeStack>();
export const columns = _.compact([
buildNameColumn<DecoratedEdgeStack>(
'Name',
'Id',
'edge.stacks.edit',
'stackId'
),
columnHelper.accessor('aggregatedStatus.acknowledged', {
header: 'Acknowledged',
enableSorting: false,
enableHiding: false,
cell: ({ getValue, row }) => (
<DeploymentCounterLink
count={getValue()}
type="Acknowledged"
stackId={row.original.Id}
/>
),
meta: {
className: '[&>*]:justify-center',
},
}),
isBE &&
columnHelper.accessor('aggregatedStatus.imagesPulled', {
header: 'Images Pre-pulled',
cell: ({ getValue, row }) => (
<DeploymentCounterLink
count={getValue()}
type="ImagesPulled"
stackId={row.original.Id}
/>
),
enableSorting: false,
enableHiding: false,
meta: {
className: '[&>*]:justify-center',
},
}),
columnHelper.accessor('aggregatedStatus.ok', {
header: 'Deployed',
cell: ({ getValue, row }) => (
<DeploymentCounterLink
count={getValue()}
type="Ok"
stackId={row.original.Id}
/>
),
enableSorting: false,
enableHiding: false,
meta: {
className: '[&>*]:justify-center',
},
}),
columnHelper.accessor('aggregatedStatus.error', {
header: 'Failed',
cell: ({ getValue, row }) => (
<DeploymentCounterLink
count={getValue()}
type="Error"
stackId={row.original.Id}
/>
),
enableSorting: false,
enableHiding: false,
meta: {
className: '[&>*]:justify-center',
},
}),
columnHelper.accessor('NumDeployments', {
header: 'Deployments',
cell: ({ getValue }) => (
<div className="text-center">
<DeploymentCounter count={getValue()} />
</div>
),
enableSorting: false,
enableHiding: false,
meta: {
className: '[&>*]:justify-center',
},
}),
columnHelper.accessor('CreationDate', {
header: 'Creation Date',
cell: ({ getValue }) => isoDateFromTimestamp(getValue()),
enableHiding: false,
}),
isBE &&
columnHelper.accessor(
(item) =>
item.GitConfig ? item.GitConfig.ConfigHash : item.StackFileVersion,
{
header: 'Target Version',
enableSorting: false,
cell: ({ row: { original: item } }) => {
if (item.GitConfig) {
return (
<div className="text-center">
<a
target="_blank"
href={`${item.GitConfig.URL}/commit/${item.GitConfig.ConfigHash}`}
rel="noreferrer"
>
{item.GitConfig.ConfigHash.slice(0, 7)}
</a>
</div>
);
}
return <div className="text-center">{item.StackFileVersion}</div>;
},
meta: {
className: '[&>*]:justify-center',
},
}
),
]);

View file

@ -0,0 +1 @@
export { EdgeStacksDatatable } from './EdgeStacksDatatable';

View file

@ -0,0 +1,20 @@
import {
BasicTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
createPersistedStore,
hiddenColumnsSettings,
refreshableSettings,
} from '@@/datatables/types';
export interface TableSettings
extends BasicTableSettings,
SettableColumnsTableSettings,
RefreshableTableSettings {}
export function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
...hiddenColumnsSettings(set),
...refreshableSettings(set),
}));
}

View file

@ -0,0 +1,12 @@
import { EdgeStack } from '../../types';
interface AggregateStackStatus {
ok: number;
error: number;
acknowledged: number;
imagesPulled: number;
}
export type DecoratedEdgeStack = EdgeStack & {
aggregatedStatus: AggregateStackStatus;
};

View file

@ -0,0 +1,35 @@
import { useMutation, useQueryClient } from 'react-query';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { EdgeStack } from '../../types';
import { buildUrl } from '../../queries/buildUrl';
import { queryKeys } from '../../queries/query-keys';
export function useDeleteEdgeStacksMutation() {
const queryClient = useQueryClient();
return useMutation(
(edgeStackIds: Array<EdgeStack['Id']>) =>
promiseSequence(
edgeStackIds.map((edgeStackId) => () => deleteEdgeStack(edgeStackId))
),
mutationOptions(
withError('Unable to delete Edge stack(s)'),
withInvalidate(queryClient, [queryKeys.base()])
)
);
}
async function deleteEdgeStack(id: EdgeStack['Id']) {
try {
await axios.delete(buildUrl(id));
} catch (e) {
throw parseAxiosError(e, 'Unable to delete edge stack');
}
}