diff --git a/app/edge/components/edge-job-results-datatable/edgeJobResultsDatatable.css b/app/edge/components/edge-job-results-datatable/edgeJobResultsDatatable.css
deleted file mode 100644
index 1b98a1e24..000000000
--- a/app/edge/components/edge-job-results-datatable/edgeJobResultsDatatable.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.edge-job-results-datatable thead th {
- width: 50%;
-}
diff --git a/app/edge/components/edge-job-results-datatable/edgeJobResultsDatatable.html b/app/edge/components/edge-job-results-datatable/edgeJobResultsDatatable.html
deleted file mode 100644
index 3313f55bc..000000000
--- a/app/edge/components/edge-job-results-datatable/edgeJobResultsDatatable.html
+++ /dev/null
@@ -1,70 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Environment
-
-
-
- |
- Actions |
-
-
-
-
-
- {{ item.Endpoint.Name }}
- |
-
-
-
-
- Logs marked for collection, please wait until the logs are available.
- |
-
-
- Loading... |
-
-
- No result available. |
-
-
-
-
-
-
-
-
diff --git a/app/edge/components/edge-job-results-datatable/edgeJobResultsDatatableController.js b/app/edge/components/edge-job-results-datatable/edgeJobResultsDatatableController.js
deleted file mode 100644
index 92217e3cd..000000000
--- a/app/edge/components/edge-job-results-datatable/edgeJobResultsDatatableController.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash-es';
-
-export class EdgeJobResultsDatatableController {
- /* @ngInject */
- constructor($controller, $scope, $state) {
- this.$state = $state;
- angular.extend(this, $controller('GenericDatatableController', { $scope }));
- }
-
- collectLogs(...args) {
- this.settings.repeater.autoRefresh = true;
- this.settings.repeater.refreshRate = '5';
- this.onSettingsRepeaterChange();
- this.onCollectLogsClick(...args);
- }
-
- $onChanges({ dataset }) {
- if (dataset && dataset.currentValue) {
- this.onDatasetChange(dataset.currentValue);
- }
- }
-
- onDatasetChange(dataset) {
- const anyCollecting = _.some(dataset, (item) => item.LogsStatus === 2);
- this.settings.repeater.autoRefresh = anyCollecting;
- this.settings.repeater.refreshRate = '5';
- this.onSettingsRepeaterChange();
- }
-}
diff --git a/app/edge/components/edge-job-results-datatable/index.js b/app/edge/components/edge-job-results-datatable/index.js
deleted file mode 100644
index 132690633..000000000
--- a/app/edge/components/edge-job-results-datatable/index.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import angular from 'angular';
-
-import { EdgeJobResultsDatatableController } from './edgeJobResultsDatatableController';
-import './edgeJobResultsDatatable.css';
-
-angular.module('portainer.edge').component('edgeJobResultsDatatable', {
- templateUrl: './edgeJobResultsDatatable.html',
- controller: EdgeJobResultsDatatableController,
- bindings: {
- titleText: '@',
- titleIcon: '@',
- dataset: '<',
- tableKey: '@',
- orderBy: '@',
- reverseOrder: '<',
- onDownloadLogsClick: '<',
- onCollectLogsClick: '<',
- onClearLogsClick: '<',
- refreshCallback: '<',
- },
-});
diff --git a/app/edge/react/components/edge-jobs.ts b/app/edge/react/components/edge-jobs.ts
new file mode 100644
index 000000000..0b88bc3d7
--- /dev/null
+++ b/app/edge/react/components/edge-jobs.ts
@@ -0,0 +1,18 @@
+import angular from 'angular';
+
+import { r2a } from '@/react-tools/react2angular';
+import { withUIRouter } from '@/react-tools/withUIRouter';
+import { ResultsDatatable } from '@/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable';
+
+export const edgeJobsModule = angular
+ .module('portainer.edge.react.components.edge-jobs', [])
+ .component(
+ 'edgeJobResultsDatatable',
+ r2a(withUIRouter(ResultsDatatable), [
+ 'dataset',
+ 'onClearLogs',
+ 'onCollectLogs',
+ 'onDownloadLogs',
+ 'onRefresh',
+ ])
+ ).name;
diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts
index b3d0eceb4..61a2016e3 100644
--- a/app/edge/react/components/index.ts
+++ b/app/edge/react/components/index.ts
@@ -15,8 +15,10 @@ import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/Asso
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
+import { edgeJobsModule } from './edge-jobs';
+
const ngModule = angular
- .module('portainer.edge.react.components', [])
+ .module('portainer.edge.react.components', [edgeJobsModule])
.component(
'edgeStackEnvironmentsDatatable',
r2a(withUIRouter(withReactQuery(EnvironmentsDatatable)), [])
diff --git a/app/edge/views/edge-jobs/edgeJob/edgeJob.html b/app/edge/views/edge-jobs/edgeJob/edgeJob.html
index 10b782cd8..152e59094 100644
--- a/app/edge/views/edge-jobs/edgeJob/edgeJob.html
+++ b/app/edge/views/edge-jobs/edgeJob/edgeJob.html
@@ -30,17 +30,13 @@
diff --git a/app/edge/views/edge-jobs/edgeJob/edgeJobController.js b/app/edge/views/edge-jobs/edgeJob/edgeJobController.js
index a65fa1275..da24e2e24 100644
--- a/app/edge/views/edge-jobs/edgeJob/edgeJobController.js
+++ b/app/edge/views/edge-jobs/edgeJob/edgeJobController.js
@@ -86,8 +86,14 @@ export class EdgeJobController {
async collectLogsAsync(endpointId) {
try {
await this.EdgeJobService.collectLogs(this.edgeJob.Id, endpointId);
- const result = _.find(this.results, (result) => result.EndpointId === endpointId);
- result.LogsStatus = 2;
+ this.results = this.results.map((result) =>
+ result.EndpointId === endpointId
+ ? {
+ ...result,
+ LogsStatus: 2,
+ }
+ : result
+ );
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to collect logs');
}
@@ -99,8 +105,14 @@ export class EdgeJobController {
async clearLogsAsync(endpointId) {
try {
await this.EdgeJobService.clearLogs(this.edgeJob.Id, endpointId);
- const result = _.find(this.results, (result) => result.EndpointId === endpointId);
- result.LogsStatus = 1;
+ this.results = this.results.map((result) =>
+ result.EndpointId === endpointId
+ ? {
+ ...result,
+ LogsStatus: 1,
+ }
+ : result
+ );
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to clear logs');
}
diff --git a/app/react/edge/edge-jobs/ItemView/.keep b/app/react/edge/edge-jobs/ItemView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx
new file mode 100644
index 000000000..68d520675
--- /dev/null
+++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx
@@ -0,0 +1,66 @@
+import { List } from 'lucide-react';
+import { useEffect } from 'react';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { Datatable } from '@@/datatables';
+import { useTableState } from '@@/datatables/useTableState';
+import { withMeta } from '@@/datatables/extend-options/withMeta';
+import { useRepeater } from '@@/datatables/useRepeater';
+
+import { LogsStatus } from '../../types';
+
+import { DecoratedJobResult } from './types';
+import { columns } from './columns';
+import { createStore } from './datatable-store';
+
+const tableKey = 'edge-job-results';
+const store = createStore(tableKey);
+
+export function ResultsDatatable({
+ dataset,
+ onCollectLogs,
+ onClearLogs,
+ onDownloadLogs,
+ onRefresh,
+}: {
+ dataset: Array;
+
+ onCollectLogs(envId: EnvironmentId): void;
+ onDownloadLogs(envId: EnvironmentId): void;
+ onClearLogs(envId: EnvironmentId): void;
+ onRefresh(): void;
+}) {
+ const anyCollecting = dataset.some(
+ (r) => r.LogsStatus === LogsStatus.Pending
+ );
+ const tableState = useTableState(store, tableKey);
+
+ const { setAutoRefreshRate } = tableState;
+
+ useEffect(() => {
+ setAutoRefreshRate(anyCollecting ? 5 : 0);
+ }, [anyCollecting, setAutoRefreshRate]);
+
+ useRepeater(tableState.autoRefreshRate, onRefresh);
+ return (
+
+ );
+
+ function handleCollectLogs(envId: EnvironmentId) {
+ onCollectLogs(envId);
+ }
+}
diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/columns.tsx b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/columns.tsx
new file mode 100644
index 000000000..7edfc3b0b
--- /dev/null
+++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/columns.tsx
@@ -0,0 +1,60 @@
+import { CellContext, createColumnHelper } from '@tanstack/react-table';
+
+import { Button } from '@@/buttons';
+
+import { LogsStatus } from '../../types';
+
+import { DecoratedJobResult, getTableMeta } from './types';
+
+const columnHelper = createColumnHelper();
+
+export const columns = [
+ columnHelper.accessor('Endpoint.Name', {
+ header: 'Environment',
+ meta: {
+ className: 'w-1/2',
+ },
+ }),
+ columnHelper.display({
+ header: 'Actions',
+ cell: ActionsCell,
+ meta: {
+ className: 'w-1/2',
+ },
+ }),
+];
+
+function ActionsCell({
+ row: { original: item },
+ table,
+}: CellContext) {
+ const tableMeta = getTableMeta(table.options.meta);
+
+ switch (item.LogsStatus) {
+ case LogsStatus.Pending:
+ return (
+ <>
+ Logs marked for collection, please wait until the logs are available.
+ >
+ );
+
+ case LogsStatus.Collected:
+ return (
+ <>
+
+
+ >
+ );
+ case LogsStatus.Idle:
+ default:
+ return (
+
+ );
+ }
+}
diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/datatable-store.ts b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/datatable-store.ts
new file mode 100644
index 000000000..d9ebb63a2
--- /dev/null
+++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/datatable-store.ts
@@ -0,0 +1,14 @@
+import {
+ refreshableSettings,
+ createPersistedStore,
+ BasicTableSettings,
+ RefreshableTableSettings,
+} from '@@/datatables/types';
+
+interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
+
+export function createStore(storageKey: string) {
+ return createPersistedStore(storageKey, undefined, (set) => ({
+ ...refreshableSettings(set),
+ }));
+}
diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/types.ts b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/types.ts
new file mode 100644
index 000000000..911922ace
--- /dev/null
+++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/types.ts
@@ -0,0 +1,34 @@
+import {
+ Environment,
+ EnvironmentId,
+} from '@/react/portainer/environments/types';
+
+import { JobResult } from '../../types';
+
+export interface DecoratedJobResult extends JobResult {
+ Endpoint: Environment;
+}
+
+interface TableMeta {
+ table: 'edge-job-results';
+ collectLogs(envId: EnvironmentId): void;
+ downloadLogs(envId: EnvironmentId): void;
+ clearLogs(envId: EnvironmentId): void;
+}
+
+function isTableMeta(meta: unknown): meta is TableMeta {
+ return (
+ !!meta &&
+ typeof meta === 'object' &&
+ 'table' in meta &&
+ meta.table === 'edge-job-results'
+ );
+}
+
+export function getTableMeta(meta: unknown): TableMeta {
+ if (!isTableMeta(meta)) {
+ throw new Error('missing correct table meta');
+ }
+
+ return meta;
+}
diff --git a/app/react/edge/edge-jobs/types.ts b/app/react/edge/edge-jobs/types.ts
index 1d1fdcc73..7327f5cfc 100644
--- a/app/react/edge/edge-jobs/types.ts
+++ b/app/react/edge/edge-jobs/types.ts
@@ -14,7 +14,7 @@ export interface EdgeJob {
GroupLogsCollection: Record;
}
-enum LogsStatus {
+export enum LogsStatus {
Idle = 1,
Pending = 2,
Collected = 3,
@@ -24,3 +24,9 @@ interface EndpointMeta {
LogsStatus: LogsStatus;
CollectLogs: boolean;
}
+
+export interface JobResult {
+ Id: string;
+ EndpointId: EnvironmentId;
+ LogsStatus: LogsStatus;
+}