diff --git a/app/edge/components/edge-jobs-datatable/edgeJobsDatatable.html b/app/edge/components/edge-jobs-datatable/edgeJobsDatatable.html
deleted file mode 100644
index b964c85c3..000000000
--- a/app/edge/components/edge-jobs-datatable/edgeJobsDatatable.html
+++ /dev/null
@@ -1,108 +0,0 @@
-
diff --git a/app/edge/components/edge-jobs-datatable/index.js b/app/edge/components/edge-jobs-datatable/index.js
deleted file mode 100644
index e91da1800..000000000
--- a/app/edge/components/edge-jobs-datatable/index.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import angular from 'angular';
-
-angular.module('portainer.edge').component('edgeJobsDatatable', {
- templateUrl: './edgeJobsDatatable.html',
- controller: 'GenericDatatableController',
- bindings: {
- titleText: '@',
- titleIcon: '@',
- dataset: '<',
- tableKey: '@',
- orderBy: '@',
- reverseOrder: '<',
- removeAction: '<',
- },
-});
diff --git a/app/edge/react/views/index.ts b/app/edge/react/views/index.ts
index 179d1f981..aaa1d21ce 100644
--- a/app/edge/react/views/index.ts
+++ b/app/edge/react/views/index.ts
@@ -9,9 +9,10 @@ import { ListView as EdgeStacksListView } from '@/react/edge/edge-stacks/ListVie
import { ListView as EdgeGroupsListView } from '@/react/edge/edge-groups/ListView';
import { templatesModule } from './templates';
+import { jobsModule } from './jobs';
export const viewsModule = angular
- .module('portainer.edge.react.views', [templatesModule])
+ .module('portainer.edge.react.views', [templatesModule, jobsModule])
.component(
'waitingRoomView',
r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), [])
diff --git a/app/edge/react/views/jobs.ts b/app/edge/react/views/jobs.ts
new file mode 100644
index 000000000..6042aa7be
--- /dev/null
+++ b/app/edge/react/views/jobs.ts
@@ -0,0 +1,13 @@
+import angular from 'angular';
+
+import { r2a } from '@/react-tools/react2angular';
+import { withCurrentUser } from '@/react-tools/withCurrentUser';
+import { withUIRouter } from '@/react-tools/withUIRouter';
+import { ListView } from '@/react/edge/edge-jobs/ListView';
+
+export const jobsModule = angular
+ .module('portainer.edge.react.views.jobs', [])
+ .component(
+ 'edgeJobsView',
+ r2a(withUIRouter(withCurrentUser(ListView)), [])
+ ).name;
diff --git a/app/edge/views/edge-jobs/edgeJobsView/edgeJobsView.html b/app/edge/views/edge-jobs/edgeJobsView/edgeJobsView.html
deleted file mode 100644
index 8d9b643ab..000000000
--- a/app/edge/views/edge-jobs/edgeJobsView/edgeJobsView.html
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
- Edge Jobs requires Docker Standalone and a cron implementation that reads jobs from /etc/cron.d
-
-
-
-
diff --git a/app/edge/views/edge-jobs/edgeJobsView/edgeJobsViewController.js b/app/edge/views/edge-jobs/edgeJobsView/edgeJobsViewController.js
deleted file mode 100644
index a9da1aacd..000000000
--- a/app/edge/views/edge-jobs/edgeJobsView/edgeJobsViewController.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import _ from 'lodash-es';
-import { confirmDelete } from '@@/modals/confirm';
-
-export class EdgeJobsViewController {
- /* @ngInject */
- constructor($async, $state, EdgeJobService, Notifications) {
- this.$async = $async;
- this.$state = $state;
- this.EdgeJobService = EdgeJobService;
- this.Notifications = Notifications;
-
- this.removeAction = this.removeAction.bind(this);
- this.deleteJobsAsync = this.deleteJobsAsync.bind(this);
- this.deleteJobs = this.deleteJobs.bind(this);
- }
-
- removeAction(selectedItems) {
- confirmDelete('Do you want to remove the selected Edge job(s)?').then((confirmed) => {
- if (!confirmed) {
- return;
- }
- this.deleteJobs(selectedItems);
- });
- }
-
- deleteJobs(edgeJobs) {
- return this.$async(this.deleteJobsAsync, edgeJobs);
- }
-
- async deleteJobsAsync(edgeJobs) {
- for (let edgeJob of edgeJobs) {
- try {
- await this.EdgeJobService.remove(edgeJob.Id);
- this.Notifications.success('Edge job successfully removed', edgeJob.Name);
- _.remove(this.edgeJobs, edgeJob);
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to remove Edge job ' + edgeJob.Name);
- }
- }
-
- this.$state.reload();
- }
-
- async $onInit() {
- try {
- const edgeJobs = await this.EdgeJobService.edgeJobs();
- this.edgeJobs = edgeJobs;
- } catch (err) {
- this.Notifications.error('Failure', err, 'Unable to retrieve Edge jobs');
- this.edgeJobs = [];
- }
- }
-}
diff --git a/app/edge/views/edge-jobs/edgeJobsView/index.js b/app/edge/views/edge-jobs/edgeJobsView/index.js
deleted file mode 100644
index 124b6e666..000000000
--- a/app/edge/views/edge-jobs/edgeJobsView/index.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import angular from 'angular';
-import { EdgeJobsViewController } from './edgeJobsViewController';
-
-angular.module('portainer.edge').component('edgeJobsView', {
- templateUrl: './edgeJobsView.html',
- controller: EdgeJobsViewController,
-});
diff --git a/app/react/edge/edge-jobs/ListView/.keep b/app/react/edge/edge-jobs/ListView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/edge/edge-jobs/ListView/EdgeJobsDatatable.tsx b/app/react/edge/edge-jobs/ListView/EdgeJobsDatatable.tsx
new file mode 100644
index 000000000..85095e335
--- /dev/null
+++ b/app/react/edge/edge-jobs/ListView/EdgeJobsDatatable.tsx
@@ -0,0 +1,34 @@
+import { Clock } from 'lucide-react';
+
+import { Datatable } from '@@/datatables';
+import { createPersistedStore } from '@@/datatables/types';
+import { useTableState } from '@@/datatables/useTableState';
+
+import { useEdgeJobs } from '../queries/useEdgeJobs';
+
+import { TableActions } from './TableActions';
+import { columns } from './columns';
+
+const tableKey = 'edge-jobs';
+
+const settingsStore = createPersistedStore(tableKey);
+
+export function EdgeJobsDatatable() {
+ const jobsQuery = useEdgeJobs();
+ const tableState = useTableState(settingsStore, tableKey);
+
+ return (
+ (
+
+ )}
+ />
+ );
+}
diff --git a/app/react/edge/edge-jobs/ListView/ListView.tsx b/app/react/edge/edge-jobs/ListView/ListView.tsx
new file mode 100644
index 000000000..3d1fdab56
--- /dev/null
+++ b/app/react/edge/edge-jobs/ListView/ListView.tsx
@@ -0,0 +1,21 @@
+import { InformationPanel } from '@@/InformationPanel';
+import { PageHeader } from '@@/PageHeader';
+
+import { EdgeJobsDatatable } from './EdgeJobsDatatable';
+
+export function ListView() {
+ return (
+ <>
+
+
+
+
+ Edge Jobs requires Docker Standalone and a cron implementation that
+ reads jobs from /etc/cron.d
+
+
+
+
+ >
+ );
+}
diff --git a/app/react/edge/edge-jobs/ListView/TableActions.tsx b/app/react/edge/edge-jobs/ListView/TableActions.tsx
new file mode 100644
index 000000000..88d74fa1c
--- /dev/null
+++ b/app/react/edge/edge-jobs/ListView/TableActions.tsx
@@ -0,0 +1,37 @@
+import { notifySuccess } from '@/portainer/services/notifications';
+
+import { AddButton } from '@@/buttons';
+import { DeleteButton } from '@@/buttons/DeleteButton';
+
+import { EdgeJob } from '../types';
+
+import { useDeleteEdgeJobsMutation } from './useDeleteEdgeJobsMutation';
+
+export function TableActions({
+ selectedItems,
+}: {
+ selectedItems: Array;
+}) {
+ const removeMutation = useDeleteEdgeJobsMutation();
+
+ return (
+
+
handleRemove(selectedItems)}
+ />
+
+ Add Edge job
+
+ );
+
+ async function handleRemove(selectedItems: Array) {
+ const ids = selectedItems.map((item) => item.Id);
+ removeMutation.mutate(ids, {
+ onSuccess: () => {
+ notifySuccess('Success', 'Edge Job(s) removed');
+ },
+ });
+ }
+}
diff --git a/app/react/edge/edge-jobs/ListView/columns.ts b/app/react/edge/edge-jobs/ListView/columns.ts
new file mode 100644
index 000000000..cba54bbd3
--- /dev/null
+++ b/app/react/edge/edge-jobs/ListView/columns.ts
@@ -0,0 +1,20 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { isoDateFromTimestamp } from '@/portainer/filters/filters';
+
+import { buildNameColumn } from '@@/datatables/buildNameColumn';
+
+import { EdgeJob } from '../types';
+
+const columnHelper = createColumnHelper();
+
+export const columns = [
+ buildNameColumn('Name', '.job'),
+ columnHelper.accessor('CronExpression', {
+ header: 'Cron Expression',
+ }),
+ columnHelper.accessor('Created', {
+ header: 'Created',
+ cell: ({ getValue }) => isoDateFromTimestamp(getValue()),
+ }),
+];
diff --git a/app/react/edge/edge-jobs/ListView/index.ts b/app/react/edge/edge-jobs/ListView/index.ts
new file mode 100644
index 000000000..dd06dfd19
--- /dev/null
+++ b/app/react/edge/edge-jobs/ListView/index.ts
@@ -0,0 +1 @@
+export { ListView } from './ListView';
diff --git a/app/react/edge/edge-jobs/ListView/useDeleteEdgeJobsMutation.ts b/app/react/edge/edge-jobs/ListView/useDeleteEdgeJobsMutation.ts
new file mode 100644
index 000000000..ee300eda3
--- /dev/null
+++ b/app/react/edge/edge-jobs/ListView/useDeleteEdgeJobsMutation.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 { EdgeJob } from '../types';
+import { buildUrl } from '../queries/build-url';
+import { queryKeys } from '../queries/query-keys';
+
+export function useDeleteEdgeJobsMutation() {
+ const queryClient = useQueryClient();
+ return useMutation(
+ (edgeJobIds: Array) =>
+ promiseSequence(
+ edgeJobIds.map((edgeJobId) => () => deleteEdgeJob(edgeJobId))
+ ),
+ mutationOptions(
+ withError('Unable to delete Edge job(s)'),
+ withInvalidate(queryClient, [queryKeys.base()])
+ )
+ );
+}
+
+async function deleteEdgeJob(id: EdgeJob['Id']) {
+ try {
+ await axios.delete(buildUrl({ id }));
+ } catch (e) {
+ throw parseAxiosError(e, 'Unable to delete edge job');
+ }
+}
diff --git a/app/react/edge/edge-jobs/queries/build-url.ts b/app/react/edge/edge-jobs/queries/build-url.ts
new file mode 100644
index 000000000..5af8083e1
--- /dev/null
+++ b/app/react/edge/edge-jobs/queries/build-url.ts
@@ -0,0 +1,10 @@
+import { EdgeJob } from '../types';
+
+export function buildUrl({
+ action,
+ id,
+}: { id?: EdgeJob['Id']; action?: string } = {}) {
+ const baseUrl = '/edge_jobs';
+ const url = id ? `${baseUrl}/${id}` : baseUrl;
+ return action ? `${url}/${action}` : url;
+}
diff --git a/app/react/edge/edge-jobs/queries/query-keys.ts b/app/react/edge/edge-jobs/queries/query-keys.ts
new file mode 100644
index 000000000..3b4b6f6c6
--- /dev/null
+++ b/app/react/edge/edge-jobs/queries/query-keys.ts
@@ -0,0 +1,3 @@
+export const queryKeys = {
+ base: () => ['edge', 'jobs'] as const,
+};
diff --git a/app/react/edge/edge-jobs/queries/useEdgeJobs.ts b/app/react/edge/edge-jobs/queries/useEdgeJobs.ts
new file mode 100644
index 000000000..a9236a250
--- /dev/null
+++ b/app/react/edge/edge-jobs/queries/useEdgeJobs.ts
@@ -0,0 +1,25 @@
+import { useQuery } from 'react-query';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+
+import { EdgeJob } from '../types';
+
+import { buildUrl } from './build-url';
+import { queryKeys } from './query-keys';
+
+async function getEdgeJobs() {
+ try {
+ const { data } = await axios.get(buildUrl());
+ return data;
+ } catch (err) {
+ throw parseAxiosError(err as Error, 'Failed fetching edge jobs');
+ }
+}
+
+export function useEdgeJobs({
+ select,
+}: {
+ select?: (jobs: EdgeJob[]) => T;
+} = {}) {
+ return useQuery(queryKeys.base(), getEdgeJobs, { select });
+}
diff --git a/app/react/edge/edge-jobs/types.ts b/app/react/edge/edge-jobs/types.ts
new file mode 100644
index 000000000..1d1fdcc73
--- /dev/null
+++ b/app/react/edge/edge-jobs/types.ts
@@ -0,0 +1,26 @@
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+export interface EdgeJob {
+ Id: number;
+ Created: number;
+ CronExpression: string;
+ Endpoints: Record;
+ EdgeGroups: number[];
+ Name: string;
+ ScriptPath: string;
+ Recurring: boolean;
+ Version: number;
+ /** Field used for log collection of Endpoints belonging to EdgeGroups */
+ GroupLogsCollection: Record;
+}
+
+enum LogsStatus {
+ Idle = 1,
+ Pending = 2,
+ Collected = 3,
+}
+
+interface EndpointMeta {
+ LogsStatus: LogsStatus;
+ CollectLogs: boolean;
+}