diff --git a/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html b/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html deleted file mode 100644 index 61af73451..000000000 --- a/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html +++ /dev/null @@ -1,173 +0,0 @@ -
- - -
-
Edge Stacks
- -
- - -
-
- - - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - - -
-
Acknowledged Deployed Failed Total deployments - -
- - - - - - {{ item.Name }} - - - - • - {{ item.aggregateStatus.acknowledged }} - - - - • - {{ item.aggregateStatus.ok }} - - - - • - {{ item.aggregateStatus.error }} - - - - {{ item.NumDeployments }} - - {{ item.CreationDate | getisodatefromtimestamp }}
Loading...
No stack available.
-
- -
-
-
diff --git a/app/edge/components/edge-stacks-datatable/index.js b/app/edge/components/edge-stacks-datatable/index.js deleted file mode 100644 index 7d974a074..000000000 --- a/app/edge/components/edge-stacks-datatable/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import angular from 'angular'; -import './edgeStackDatatable.css'; - -angular.module('portainer.edge').component('edgeStacksDatatable', { - templateUrl: './edgeStacksDatatable.html', - controller: 'GenericDatatableController', - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - orderBy: '@', - reverseOrder: '<', - removeAction: '<', - refreshCallback: '<', - }, -}); diff --git a/app/edge/react/views/index.ts b/app/edge/react/views/index.ts index 4397b58d8..29730c057 100644 --- a/app/edge/react/views/index.ts +++ b/app/edge/react/views/index.ts @@ -5,10 +5,15 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { WaitingRoomView } from '@/react/edge/edge-devices/WaitingRoomView'; +import { ListView } from '@/react/edge/edge-stacks/ListView'; export const viewsModule = angular .module('portainer.edge.react.views', []) .component( 'waitingRoomView', r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), []) + ) + .component( + 'edgeStacksView', + r2a(withUIRouter(withCurrentUser(ListView)), []) ).name; diff --git a/app/edge/views/edge-stacks/edgeStacksView/edgeStacksView.html b/app/edge/views/edge-stacks/edgeStacksView/edgeStacksView.html deleted file mode 100644 index 8fdebb183..000000000 --- a/app/edge/views/edge-stacks/edgeStacksView/edgeStacksView.html +++ /dev/null @@ -1,15 +0,0 @@ - - -
-
- -
-
diff --git a/app/edge/views/edge-stacks/edgeStacksView/edgeStacksViewController.js b/app/edge/views/edge-stacks/edgeStacksView/edgeStacksViewController.js deleted file mode 100644 index aaa62e8b2..000000000 --- a/app/edge/views/edge-stacks/edgeStacksView/edgeStacksViewController.js +++ /dev/null @@ -1,77 +0,0 @@ -import _ from 'lodash-es'; -import { confirmDestructive } from '@@/modals/confirm'; -import { buildConfirmButton } from '@@/modals/utils'; - -export class EdgeStacksViewController { - /* @ngInject */ - constructor($state, Notifications, EdgeStackService, $scope, $async) { - this.$state = $state; - this.Notifications = Notifications; - this.EdgeStackService = EdgeStackService; - this.$scope = $scope; - this.$async = $async; - - this.stacks = undefined; - - this.getStacks = this.getStacks.bind(this); - this.removeAction = this.removeAction.bind(this); - this.removeActionAsync = this.removeActionAsync.bind(this); - } - - $onInit() { - this.getStacks(); - } - - removeAction(stacks) { - return this.$async(this.removeActionAsync, stacks); - } - - async removeActionAsync(stacks) { - 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; - } - - for (let stack of stacks) { - try { - await this.EdgeStackService.remove(stack.Id); - this.Notifications.success('Stack successfully removed', stack.Name); - _.remove(this.stacks, stack); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name); - } - } - - this.$state.reload(); - } - - aggregateStatus() { - if (this.stacks) { - this.stacks.forEach((stack) => { - const aggregateStatus = { ok: 0, error: 0, acknowledged: 0 }; - for (let endpointId in stack.Status) { - const { Details } = stack.Status[endpointId]; - aggregateStatus.ok += Number(Details.Ok); - aggregateStatus.error += Number(Details.Error); - aggregateStatus.acknowledged += Number(Details.Acknowledged); - } - stack.aggregateStatus = aggregateStatus; - }); - } - } - - async getStacks() { - try { - this.stacks = await this.EdgeStackService.stacks(); - this.aggregateStatus(); - } catch (err) { - this.stacks = []; - this.Notifications.error('Failure', err, 'Unable to retrieve stacks'); - } - } -} diff --git a/app/edge/views/edge-stacks/edgeStacksView/index.js b/app/edge/views/edge-stacks/edgeStacksView/index.js deleted file mode 100644 index 663c6ea41..000000000 --- a/app/edge/views/edge-stacks/edgeStacksView/index.js +++ /dev/null @@ -1,8 +0,0 @@ -import angular from 'angular'; - -import { EdgeStacksViewController } from './edgeStacksViewController'; - -angular.module('portainer.edge').component('edgeStacksView', { - templateUrl: './edgeStacksView.html', - controller: EdgeStacksViewController, -}); diff --git a/app/react/components/datatables/NameCell.tsx b/app/react/components/datatables/NameCell.tsx index 9d5db635e..a388fd76c 100644 --- a/app/react/components/datatables/NameCell.tsx +++ b/app/react/components/datatables/NameCell.tsx @@ -5,7 +5,8 @@ import { Link } from '@@/Link'; export function buildNameColumn>( nameKey: keyof T, idKey: string, - path: string + path: string, + idParam = 'id' ): ColumnDef { const cell = createCell(); @@ -15,6 +16,7 @@ export function buildNameColumn>( id: 'name', cell, enableSorting: true, + enableHiding: false, sortingFn: 'text', }; @@ -27,7 +29,11 @@ export function buildNameColumn>( } return ( - + {name} ); diff --git a/app/react/edge/edge-stacks/.keep b/app/react/edge/edge-stacks/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/edge/edge-stacks/ListView/.keep b/app/react/edge/edge-stacks/ListView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/edge/components/edge-stacks-datatable/edgeStackDatatable.css b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.module.css similarity index 63% rename from app/edge/components/edge-stacks-datatable/edgeStackDatatable.css rename to app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.module.css index ad545b235..7676eee2b 100644 --- a/app/edge/components/edge-stacks-datatable/edgeStackDatatable.css +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.module.css @@ -1,28 +1,28 @@ -.edge-stack-status { +.root { padding: 2px 10px; border-radius: 10px; } -.edge-stack-status.status-acknowledged { +.status-acknowledged { color: #337ab7; background-color: rgba(51, 122, 183, 0.1); } -.edge-stack-status.status-images-pulled { +.status-images-pulled { color: #e1a800; background-color: rgba(238, 192, 32, 0.1); } -.edge-stack-status.status-ok { +.status-ok { color: #23ae89; background-color: rgba(35, 174, 137, 0.1); } -.edge-stack-status.status-error { +.status-error { color: #ae2323; background-color: rgba(174, 35, 35, 0.1); } -.edge-stack-status.status-total { +.status-total { background-color: rgba(168, 167, 167, 0.1); } diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.tsx new file mode 100644 index 000000000..1618fd673 --- /dev/null +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.tsx @@ -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 ( +
+ + + +
+ ); +} + +export function DeploymentCounter({ + count, + type, +}: { + count: number; + type?: StatusType; +}) { + return ( + + • {count} + + ); +} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx new file mode 100644 index 000000000..1bca53d4f --- /dev/null +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx @@ -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>({ + select: (edgeStacks) => + edgeStacks.map((edgeStack) => ({ + ...edgeStack, + aggregatedStatus: aggregateStackStatus(edgeStack.Status), + })), + refetchInterval: tableState.autoRefreshRate * 1000, + }); + + return ( + ( + + )} + renderTableActions={(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); +} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/TableActions.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/TableActions.tsx new file mode 100644 index 000000000..53a2254d6 --- /dev/null +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/TableActions.tsx @@ -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; +}) { + const removeMutation = useDeleteEdgeStacksMutation(); + + return ( +
+ + + +
+ ); + + async function handleRemove(selectedItems: Array) { + 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'); + }, + }); + } +} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/TableSettingsMenus.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/TableSettingsMenus.tsx new file mode 100644 index 000000000..47e49b8b3 --- /dev/null +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/TableSettingsMenus.tsx @@ -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; + tableState: TableSettings; +}) { + const columnsToHide = tableInstance + .getAllColumns() + .filter((col) => col.getCanHide()); + + return ( + <> + + columns={columnsToHide} + onChange={(hiddenColumns) => { + tableState.setHiddenColumns(hiddenColumns); + tableInstance.setColumnVisibility( + Object.fromEntries(hiddenColumns.map((col) => [col, false])) + ); + }} + value={tableState.hiddenColumns} + /> + + tableState.setAutoRefreshRate(value)} + /> + + + ); +} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx new file mode 100644 index 000000000..859c515f4 --- /dev/null +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx @@ -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(); + +export const columns = _.compact([ + buildNameColumn( + 'Name', + 'Id', + 'edge.stacks.edit', + 'stackId' + ), + columnHelper.accessor('aggregatedStatus.acknowledged', { + header: 'Acknowledged', + enableSorting: false, + enableHiding: false, + cell: ({ getValue, row }) => ( + + ), + meta: { + className: '[&>*]:justify-center', + }, + }), + isBE && + columnHelper.accessor('aggregatedStatus.imagesPulled', { + header: 'Images Pre-pulled', + cell: ({ getValue, row }) => ( + + ), + enableSorting: false, + enableHiding: false, + meta: { + className: '[&>*]:justify-center', + }, + }), + columnHelper.accessor('aggregatedStatus.ok', { + header: 'Deployed', + cell: ({ getValue, row }) => ( + + ), + enableSorting: false, + enableHiding: false, + meta: { + className: '[&>*]:justify-center', + }, + }), + columnHelper.accessor('aggregatedStatus.error', { + header: 'Failed', + cell: ({ getValue, row }) => ( + + ), + enableSorting: false, + enableHiding: false, + meta: { + className: '[&>*]:justify-center', + }, + }), + columnHelper.accessor('NumDeployments', { + header: 'Deployments', + cell: ({ getValue }) => ( +
+ +
+ ), + 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 ( + + ); + } + + return
{item.StackFileVersion}
; + }, + meta: { + className: '[&>*]:justify-center', + }, + } + ), +]); diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/index.ts b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/index.ts new file mode 100644 index 000000000..d90a90b10 --- /dev/null +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/index.ts @@ -0,0 +1 @@ +export { EdgeStacksDatatable } from './EdgeStacksDatatable'; diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/store.ts b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/store.ts new file mode 100644 index 000000000..e77ca8e21 --- /dev/null +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/store.ts @@ -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(storageKey, 'name', (set) => ({ + ...hiddenColumnsSettings(set), + ...refreshableSettings(set), + })); +} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/types.ts b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/types.ts new file mode 100644 index 000000000..6b1463061 --- /dev/null +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/types.ts @@ -0,0 +1,12 @@ +import { EdgeStack } from '../../types'; + +interface AggregateStackStatus { + ok: number; + error: number; + acknowledged: number; + imagesPulled: number; +} + +export type DecoratedEdgeStack = EdgeStack & { + aggregatedStatus: AggregateStackStatus; +}; diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/useDeleteEdgeStacksMutation.ts b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/useDeleteEdgeStacksMutation.ts new file mode 100644 index 000000000..1c07d1c53 --- /dev/null +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/useDeleteEdgeStacksMutation.ts @@ -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) => + 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'); + } +} diff --git a/app/react/edge/edge-stacks/ListView/ListView.tsx b/app/react/edge/edge-stacks/ListView/ListView.tsx new file mode 100644 index 000000000..a2a25cea8 --- /dev/null +++ b/app/react/edge/edge-stacks/ListView/ListView.tsx @@ -0,0 +1,13 @@ +import { PageHeader } from '@@/PageHeader'; + +import { EdgeStacksDatatable } from './EdgeStacksDatatable'; + +export function ListView() { + return ( + <> + + + + + ); +} diff --git a/app/react/edge/edge-stacks/ListView/index.ts b/app/react/edge/edge-stacks/ListView/index.ts new file mode 100644 index 000000000..dd06dfd19 --- /dev/null +++ b/app/react/edge/edge-stacks/ListView/index.ts @@ -0,0 +1 @@ +export { ListView } from './ListView'; diff --git a/app/react/edge/edge-stacks/queries/useEdgeStacks.ts b/app/react/edge/edge-stacks/queries/useEdgeStacks.ts new file mode 100644 index 000000000..f420febd1 --- /dev/null +++ b/app/react/edge/edge-stacks/queries/useEdgeStacks.ts @@ -0,0 +1,36 @@ +import { useQuery } from 'react-query'; + +import { withError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { EdgeStack } from '../types'; + +import { buildUrl } from './buildUrl'; + +export function useEdgeStacks>({ + 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`. + */ + refetchInterval, +}: { + select?: (stacks: EdgeStack[]) => T; + refetchInterval?: number | false | ((data?: T) => false | number); +} = {}) { + return useQuery(['edge_stacks'], () => getEdgeStacks(), { + ...withError('Failed loading Edge stack'), + select, + refetchInterval, + }); +} + +export async function getEdgeStacks() { + try { + const { data } = await axios.get(buildUrl()); + return data; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/react/edge/edge-stacks/types.ts b/app/react/edge/edge-stacks/types.ts index 88ab61ff7..9ffe137e4 100644 --- a/app/react/edge/edge-stacks/types.ts +++ b/app/react/edge/edge-stacks/types.ts @@ -59,6 +59,7 @@ export type EdgeStack = { Prune: boolean; RetryDeploy: boolean; Webhook?: string; + StackFileVersion?: number; EnvVars?: EnvVar[]; };