diff --git a/app/edge/components/groups-datatable/groupsDatatable.html b/app/edge/components/groups-datatable/groupsDatatable.html
deleted file mode 100644
index 271c37a12..000000000
--- a/app/edge/components/groups-datatable/groupsDatatable.html
+++ /dev/null
@@ -1,126 +0,0 @@
-
diff --git a/app/edge/components/groups-datatable/groupsDatatableController.js b/app/edge/components/groups-datatable/groupsDatatableController.js
deleted file mode 100644
index 3f7cdf774..000000000
--- a/app/edge/components/groups-datatable/groupsDatatableController.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import angular from 'angular';
-
-export class EdgeGroupsDatatableController {
- /* @ngInject */
- constructor($scope, $controller) {
- const allowSelection = this.allowSelection;
- angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
- this.allowSelection = allowSelection.bind(this);
- }
-
- /**
- * Override this method to allow/deny selection
- */
- allowSelection(item) {
- return !item.HasEdgeStack;
- }
-}
diff --git a/app/edge/components/groups-datatable/index.js b/app/edge/components/groups-datatable/index.js
deleted file mode 100644
index 409551a94..000000000
--- a/app/edge/components/groups-datatable/index.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import angular from 'angular';
-
-import { EdgeGroupsDatatableController } from './groupsDatatableController';
-
-angular.module('portainer.edge').component('edgeGroupsDatatable', {
- templateUrl: './groupsDatatable.html',
- controller: EdgeGroupsDatatableController,
- bindings: {
- dataset: '<',
- titleIcon: '@',
- tableKey: '@',
- orderBy: '@',
- removeAction: '<',
- updateAction: '<',
- reverseOrder: '<',
- },
-});
diff --git a/app/edge/react/views/index.ts b/app/edge/react/views/index.ts
index 29730c057..28a8bf1b4 100644
--- a/app/edge/react/views/index.ts
+++ b/app/edge/react/views/index.ts
@@ -5,7 +5,8 @@ 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';
+import { ListView as EdgeStacksListView } from '@/react/edge/edge-stacks/ListView';
+import { ListView as EdgeGroupsListView } from '@/react/edge/edge-groups/ListView';
export const viewsModule = angular
.module('portainer.edge.react.views', [])
@@ -15,5 +16,9 @@ export const viewsModule = angular
)
.component(
'edgeStacksView',
- r2a(withUIRouter(withCurrentUser(ListView)), [])
+ r2a(withUIRouter(withCurrentUser(EdgeStacksListView)), [])
+ )
+ .component(
+ 'edgeGroupsView',
+ r2a(withUIRouter(withCurrentUser(EdgeGroupsListView)), [])
).name;
diff --git a/app/edge/views/edge-groups/edgeGroupsView/edgeGroupsView.html b/app/edge/views/edge-groups/edgeGroupsView/edgeGroupsView.html
deleted file mode 100644
index 747e097ad..000000000
--- a/app/edge/views/edge-groups/edgeGroupsView/edgeGroupsView.html
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
diff --git a/app/edge/views/edge-groups/edgeGroupsView/edgeGroupsViewController.js b/app/edge/views/edge-groups/edgeGroupsView/edgeGroupsViewController.js
deleted file mode 100644
index 989cf3c49..000000000
--- a/app/edge/views/edge-groups/edgeGroupsView/edgeGroupsViewController.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import _ from 'lodash-es';
-import { confirmDelete } from '@@/modals/confirm';
-
-export class EdgeGroupsController {
- /* @ngInject */
- constructor($async, $state, EdgeGroupService, Notifications) {
- this.$async = $async;
- this.$state = $state;
- this.EdgeGroupService = EdgeGroupService;
- this.Notifications = Notifications;
-
- this.removeAction = this.removeAction.bind(this);
- this.removeActionAsync = this.removeActionAsync.bind(this);
- }
-
- async $onInit() {
- try {
- this.items = await this.EdgeGroupService.groups();
- } catch (err) {
- this.items = [];
- this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
- }
- }
-
- removeAction(selectedItems) {
- return this.$async(this.removeActionAsync, selectedItems);
- }
-
- async removeActionAsync(selectedItems) {
- if (!(await confirmDelete('Do you want to remove the selected Edge Group(s)?'))) {
- return;
- }
-
- for (let item of selectedItems) {
- try {
- await this.EdgeGroupService.remove(item.Id);
-
- this.Notifications.success('Edge Group successfully removed', item.Name);
- _.remove(this.items, item);
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to remove Edge Group');
- }
- }
-
- this.$state.reload();
- }
-}
diff --git a/app/edge/views/edge-groups/edgeGroupsView/index.js b/app/edge/views/edge-groups/edgeGroupsView/index.js
deleted file mode 100644
index 2c511421b..000000000
--- a/app/edge/views/edge-groups/edgeGroupsView/index.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import angular from 'angular';
-
-import { EdgeGroupsController } from './edgeGroupsViewController';
-
-angular.module('portainer.edge').component('edgeGroupsView', {
- templateUrl: './edgeGroupsView.html',
- controller: EdgeGroupsController,
-});
diff --git a/app/react/edge/edge-groups/ListView/.keep b/app/react/edge/edge-groups/ListView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/edge/edge-groups/ListView/EdgeGroupsDatatable.tsx b/app/react/edge/edge-groups/ListView/EdgeGroupsDatatable.tsx
new file mode 100644
index 000000000..8a7d98761
--- /dev/null
+++ b/app/react/edge/edge-groups/ListView/EdgeGroupsDatatable.tsx
@@ -0,0 +1,37 @@
+import { LayoutGrid } from 'lucide-react';
+
+import { Datatable } from '@@/datatables';
+import { useTableState } from '@@/datatables/useTableState';
+import { createPersistedStore } from '@@/datatables/types';
+
+import { useEdgeGroups } from '../queries/useEdgeGroups';
+
+import { columns } from './columns';
+import { TableActions } from './TableActions';
+
+const tableKey = 'edge-groups';
+
+const settingsStore = createPersistedStore(tableKey);
+
+export function EdgeGroupsDatatable() {
+ const tableState = useTableState(settingsStore, tableKey);
+ const edgeGroupsQuery = useEdgeGroups();
+
+ return (
+ (
+
+ )}
+ isRowSelectable={({ original: item }) =>
+ !(item.HasEdgeStack || item.HasEdgeJob || item.HasEdgeConfig)
+ }
+ />
+ );
+}
diff --git a/app/react/edge/edge-groups/ListView/ListView.tsx b/app/react/edge/edge-groups/ListView/ListView.tsx
new file mode 100644
index 000000000..97aed7b52
--- /dev/null
+++ b/app/react/edge/edge-groups/ListView/ListView.tsx
@@ -0,0 +1,12 @@
+import { PageHeader } from '@@/PageHeader';
+
+import { EdgeGroupsDatatable } from './EdgeGroupsDatatable';
+
+export function ListView() {
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/app/react/edge/edge-groups/ListView/TableActions.tsx b/app/react/edge/edge-groups/ListView/TableActions.tsx
new file mode 100644
index 000000000..5d38e45b3
--- /dev/null
+++ b/app/react/edge/edge-groups/ListView/TableActions.tsx
@@ -0,0 +1,37 @@
+import { notifySuccess } from '@/portainer/services/notifications';
+
+import { AddButton } from '@@/buttons';
+import { DeleteButton } from '@@/buttons/DeleteButton';
+
+import { EdgeGroup } from '../types';
+
+import { useDeleteEdgeGroupsMutation } from './useDeleteEdgeGroupMutation';
+
+export function TableActions({
+ selectedItems,
+}: {
+ selectedItems: Array;
+}) {
+ const removeMutation = useDeleteEdgeGroupsMutation();
+
+ return (
+
+
handleRemove(selectedItems)}
+ />
+
+ Add Edge group
+
+ );
+
+ async function handleRemove(selectedItems: Array) {
+ const ids = selectedItems.map((item) => item.Id);
+ removeMutation.mutate(ids, {
+ onSuccess: () => {
+ notifySuccess('Success', 'Edge Group(s) removed');
+ },
+ });
+ }
+}
diff --git a/app/react/edge/edge-groups/ListView/columns/helper.ts b/app/react/edge/edge-groups/ListView/columns/helper.ts
new file mode 100644
index 000000000..d4e142ba9
--- /dev/null
+++ b/app/react/edge/edge-groups/ListView/columns/helper.ts
@@ -0,0 +1,5 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { EdgeGroupListItemResponse } from '../../queries/useEdgeGroups';
+
+export const columnHelper = createColumnHelper();
diff --git a/app/react/edge/edge-groups/ListView/columns/index.ts b/app/react/edge/edge-groups/ListView/columns/index.ts
new file mode 100644
index 000000000..7b1d5e469
--- /dev/null
+++ b/app/react/edge/edge-groups/ListView/columns/index.ts
@@ -0,0 +1,13 @@
+import { columnHelper } from './helper';
+import { name } from './name';
+
+export const columns = [
+ name,
+ columnHelper.accessor((group) => group.TrustedEndpoints.length, {
+ header: 'Environments Count',
+ }),
+ columnHelper.accessor('Dynamic', {
+ header: 'Group Type',
+ cell: ({ getValue }) => (getValue() ? 'Dynamic' : 'Static'),
+ }),
+];
diff --git a/app/react/edge/edge-groups/ListView/columns/name.tsx b/app/react/edge/edge-groups/ListView/columns/name.tsx
new file mode 100644
index 000000000..a92fb9b08
--- /dev/null
+++ b/app/react/edge/edge-groups/ListView/columns/name.tsx
@@ -0,0 +1,34 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { Link } from '@@/Link';
+
+import { EdgeGroupListItemResponse } from '../../queries/useEdgeGroups';
+
+import { columnHelper } from './helper';
+
+export const name = columnHelper.accessor('Name', {
+ header: 'Name',
+ cell: NameCell,
+});
+
+function NameCell({
+ renderValue,
+ row: { original: item },
+}: CellContext) {
+ const name = renderValue() || '';
+
+ if (typeof name !== 'string') {
+ return null;
+ }
+
+ return (
+ <>
+
+ {name}
+
+ {(item.HasEdgeJob || item.HasEdgeStack) && (
+ in use
+ )}
+ >
+ );
+}
diff --git a/app/react/edge/edge-groups/ListView/index.ts b/app/react/edge/edge-groups/ListView/index.ts
new file mode 100644
index 000000000..dd06dfd19
--- /dev/null
+++ b/app/react/edge/edge-groups/ListView/index.ts
@@ -0,0 +1 @@
+export { ListView } from './ListView';
diff --git a/app/react/edge/edge-groups/ListView/useDeleteEdgeGroupMutation.ts b/app/react/edge/edge-groups/ListView/useDeleteEdgeGroupMutation.ts
new file mode 100644
index 000000000..8cb262a3b
--- /dev/null
+++ b/app/react/edge/edge-groups/ListView/useDeleteEdgeGroupMutation.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 { EdgeGroup } from '../types';
+import { buildUrl } from '../queries/build-url';
+import { queryKeys } from '../queries/query-keys';
+
+export function useDeleteEdgeGroupsMutation() {
+ const queryClient = useQueryClient();
+ return useMutation(
+ (edgeGroupIds: Array) =>
+ promiseSequence(
+ edgeGroupIds.map((edgeGroupId) => () => deleteEdgeGroup(edgeGroupId))
+ ),
+ mutationOptions(
+ withError('Unable to delete Edge Group(s)'),
+ withInvalidate(queryClient, [queryKeys.base()])
+ )
+ );
+}
+
+async function deleteEdgeGroup(id: EdgeGroup['Id']) {
+ try {
+ await axios.delete(buildUrl({ id }));
+ } catch (e) {
+ throw parseAxiosError(e, 'Unable to delete edge Group');
+ }
+}
diff --git a/app/react/edge/edge-groups/queries/build-url.ts b/app/react/edge/edge-groups/queries/build-url.ts
index 720780dd0..dd4ae1d68 100644
--- a/app/react/edge/edge-groups/queries/build-url.ts
+++ b/app/react/edge/edge-groups/queries/build-url.ts
@@ -1,3 +1,10 @@
-export function buildUrl() {
- return '/edge_groups';
+import { EdgeGroup } from '../types';
+
+export function buildUrl({
+ action,
+ id,
+}: { id?: EdgeGroup['Id']; action?: string } = {}) {
+ const baseUrl = '/edge_groups';
+ const url = id ? `${baseUrl}/${id}` : baseUrl;
+ return action ? `${url}/${action}` : url;
}
diff --git a/app/react/edge/edge-groups/queries/useEdgeGroups.ts b/app/react/edge/edge-groups/queries/useEdgeGroups.ts
index 9adea5fb3..5c6d3952d 100644
--- a/app/react/edge/edge-groups/queries/useEdgeGroups.ts
+++ b/app/react/edge/edge-groups/queries/useEdgeGroups.ts
@@ -1,15 +1,22 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
-import { EnvironmentType } from '@/react/portainer/environments/types';
+import {
+ EnvironmentId,
+ EnvironmentType,
+} from '@/react/portainer/environments/types';
import { EdgeGroup } from '../types';
import { queryKeys } from './query-keys';
import { buildUrl } from './build-url';
-interface EdgeGroupListItemResponse extends EdgeGroup {
+export interface EdgeGroupListItemResponse extends EdgeGroup {
EndpointTypes: Array;
+ HasEdgeStack?: boolean;
+ HasEdgeJob?: boolean;
+ HasEdgeConfig?: boolean;
+ TrustedEndpoints: Array;
}
async function getEdgeGroups() {