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 @@ -
- - -
-
-
- -
- {{ $ctrl.titleText }} -
- - -
- - -
-
-
- - - - - - - - - - - - - - - - - - - - - -
-
- - - - - -
-
- - - -
- - - - - {{ item.Name }} - - {{ item.CronExpression }} - {{ item.Created | getisodatefromtimestamp }}
Loading...
No Edge job available.
-
- -
-
-
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; +}