diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html
deleted file mode 100644
index 734414b09..000000000
--- a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html
+++ /dev/null
@@ -1,109 +0,0 @@
-
diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html
index f53d42405..fed57be91 100644
--- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html
+++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html
@@ -89,13 +89,7 @@
>
-
+
= []) {
return command.join(' ');
}
+
+export function taskStatusBadge(text?: TaskState) {
+ const status = _.toLower(text);
+ if (
+ [
+ 'new',
+ 'allocated',
+ 'assigned',
+ 'accepted',
+ 'preparing',
+ 'ready',
+ 'starting',
+ 'remove',
+ ].includes(status)
+ ) {
+ return 'info';
+ }
+
+ if (['pending'].includes(status)) {
+ return 'warning';
+ }
+
+ if (['shutdown', 'failed', 'rejected', 'orphaned'].includes(status)) {
+ return 'danger';
+ }
+
+ if (['complete'].includes(status)) {
+ return 'primary';
+ }
+
+ if (['running'].includes(status)) {
+ return 'success';
+ }
+ return 'default';
+}
diff --git a/app/docker/models/task.js b/app/docker/models/task.js
deleted file mode 100644
index 6e16b3a56..000000000
--- a/app/docker/models/task.js
+++ /dev/null
@@ -1,14 +0,0 @@
-export function TaskViewModel(data) {
- this.Id = data.ID;
- this.Created = data.CreatedAt;
- this.Updated = data.UpdatedAt;
- this.Slot = data.Slot;
- this.Spec = data.Spec;
- this.Status = data.Status;
- this.DesiredState = data.DesiredState;
- this.ServiceId = data.ServiceID;
- this.NodeId = data.NodeID;
- if (data.Status && data.Status.ContainerStatus && data.Status.ContainerStatus.ContainerID) {
- this.ContainerId = data.Status.ContainerStatus.ContainerID;
- }
-}
diff --git a/app/docker/models/task.ts b/app/docker/models/task.ts
new file mode 100644
index 000000000..c67a41171
--- /dev/null
+++ b/app/docker/models/task.ts
@@ -0,0 +1,36 @@
+import { Task, TaskSpec, TaskState } from 'docker-types/generated/1.41';
+
+export class TaskViewModel {
+ Id: string;
+
+ Created: string;
+
+ Updated: string;
+
+ Slot: number;
+
+ Spec?: TaskSpec;
+
+ Status: Task['Status'];
+
+ DesiredState: TaskState;
+
+ ServiceId: string;
+
+ NodeId: string;
+
+ ContainerId: string = '';
+
+ constructor(data: Task) {
+ this.Id = data.ID || '';
+ this.Created = data.CreatedAt || '';
+ this.Updated = data.UpdatedAt || '';
+ this.Slot = data.Slot || 0;
+ this.Spec = data.Spec;
+ this.Status = data.Status;
+ this.DesiredState = data.DesiredState || 'pending';
+ this.ServiceId = data.ServiceID || '';
+ this.NodeId = data.NodeID || '';
+ this.ContainerId = data.Status?.ContainerStatus?.ContainerID || '';
+ }
+}
diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts
index f7c6ecbb5..1edeeb660 100644
--- a/app/docker/react/components/index.ts
+++ b/app/docker/react/components/index.ts
@@ -25,9 +25,13 @@ import { ScaleServiceButton } from '@/react/docker/services/ListView/ServicesDat
import { SecretsDatatable } from '@/react/docker/secrets/ListView/SecretsDatatable';
import { containersModule } from './containers';
+import { servicesModule } from './services';
const ngModule = angular
- .module('portainer.docker.react.components', [containersModule])
+ .module('portainer.docker.react.components', [
+ containersModule,
+ servicesModule,
+ ])
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
.component(
@@ -37,7 +41,6 @@ const ngModule = angular
'nodeName',
'state',
'status',
- 'taskId',
])
)
.component('templateListDropdown', TemplateListDropdownAngular)
diff --git a/app/docker/react/components/services.ts b/app/docker/react/components/services.ts
new file mode 100644
index 000000000..ec4634823
--- /dev/null
+++ b/app/docker/react/components/services.ts
@@ -0,0 +1,21 @@
+import angular from 'angular';
+
+import { r2a } from '@/react-tools/react2angular';
+import { withUIRouter } from '@/react-tools/withUIRouter';
+import { TasksDatatable } from '@/react/docker/services/ListView/ServicesDatatable/TasksDatatable';
+import { withCurrentUser } from '@/react-tools/withCurrentUser';
+import { TaskTableQuickActions } from '@/react/docker/services/common/TaskTableQuickActions';
+
+export const servicesModule = angular
+ .module('portainer.docker.react.components.services', [])
+ .component(
+ 'dockerServiceTasksDatatable',
+ r2a(withUIRouter(withCurrentUser(TasksDatatable)), ['dataset', 'search'])
+ )
+ .component(
+ 'dockerTaskTableQuickActions',
+ r2a(withUIRouter(withCurrentUser(TaskTableQuickActions)), [
+ 'state',
+ 'taskId',
+ ])
+ ).name;
diff --git a/app/react/components/datatables/NestedDatatable.tsx b/app/react/components/datatables/NestedDatatable.tsx
index 1b0eaaa3b..6569435d5 100644
--- a/app/react/components/datatables/NestedDatatable.tsx
+++ b/app/react/components/datatables/NestedDatatable.tsx
@@ -23,6 +23,11 @@ interface Props {
initialTableState?: Partial;
isLoading?: boolean;
initialSortBy?: BasicTableSettings['sortBy'];
+
+ /**
+ * keyword to filter by
+ */
+ search?: string;
}
export function NestedDatatable({
@@ -33,6 +38,7 @@ export function NestedDatatable({
initialTableState = {},
isLoading,
initialSortBy,
+ search,
}: Props) {
const tableInstance = useReactTable({
columns,
@@ -45,6 +51,9 @@ export function NestedDatatable({
enableColumnFilter: false,
enableHiding: false,
},
+ state: {
+ globalFilter: search,
+ },
getRowId,
autoResetExpanded: false,
getCoreRowModel: getCoreRowModel(),
@@ -55,7 +64,7 @@ export function NestedDatatable({
return (
-
+
tableInstance={tableInstance}
isLoading={isLoading}
diff --git a/app/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions.tsx b/app/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions.tsx
index 81ac473d8..891f40a7a 100644
--- a/app/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions.tsx
+++ b/app/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions.tsx
@@ -9,7 +9,7 @@ import { Link } from '@@/Link';
import styles from './ContainerQuickActions.module.css';
-interface QuickActionsState {
+export interface QuickActionsState {
showQuickActionAttach: boolean;
showQuickActionExec: boolean;
showQuickActionInspect: boolean;
@@ -17,31 +17,25 @@ interface QuickActionsState {
showQuickActionStats: boolean;
}
-interface Props {
- taskId?: string;
- containerId?: string;
- nodeName: string;
- state: QuickActionsState;
- status: ContainerStatus;
-}
-
export function ContainerQuickActions({
- taskId,
+ status,
containerId,
nodeName,
state,
- status,
-}: Props) {
- if (taskId) {
- return ;
- }
-
- const isActive = [
- ContainerStatus.Starting,
- ContainerStatus.Running,
- ContainerStatus.Healthy,
- ContainerStatus.Unhealthy,
- ].includes(status);
+}: {
+ containerId: string;
+ nodeName: string;
+ status: ContainerStatus;
+ state: QuickActionsState;
+}) {
+ const isActive =
+ !!status &&
+ [
+ ContainerStatus.Starting,
+ ContainerStatus.Running,
+ ContainerStatus.Healthy,
+ ContainerStatus.Unhealthy,
+ ].includes(status);
return (
@@ -107,34 +101,3 @@ export function ContainerQuickActions({
);
}
-
-interface TaskProps {
- taskId: string;
- state: QuickActionsState;
-}
-
-function TaskQuickActions({ taskId, state }: TaskProps) {
- return (
-
- {state.showQuickActionLogs && (
-
-
-
-
-
- )}
-
- {state.showQuickActionInspect && (
-
-
-
-
-
- )}
-
- );
-}
diff --git a/app/react/docker/proxy/queries/nodes/build-url.ts b/app/react/docker/proxy/queries/nodes/build-url.ts
new file mode 100644
index 000000000..2c422d18e
--- /dev/null
+++ b/app/react/docker/proxy/queries/nodes/build-url.ts
@@ -0,0 +1,15 @@
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { buildUrl as buildProxyUrl } from '../build-url';
+
+export function buildUrl(
+ environmentId: EnvironmentId,
+ action?: string,
+ subAction = ''
+) {
+ return buildProxyUrl(
+ environmentId,
+ 'nodes',
+ subAction ? `${action}/${subAction}` : action
+ );
+}
diff --git a/app/react/docker/proxy/queries/nodes/query-keys.ts b/app/react/docker/proxy/queries/nodes/query-keys.ts
new file mode 100644
index 000000000..3f599e1d8
--- /dev/null
+++ b/app/react/docker/proxy/queries/nodes/query-keys.ts
@@ -0,0 +1,8 @@
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { queryKeys as proxyQueryKeys } from '../query-keys';
+
+export const queryKeys = {
+ base: (environmentId: EnvironmentId) =>
+ [...proxyQueryKeys.base(environmentId), 'nodes'] as const,
+};
diff --git a/app/react/docker/proxy/queries/nodes/useNodes.ts b/app/react/docker/proxy/queries/nodes/useNodes.ts
new file mode 100644
index 000000000..cc52ffae1
--- /dev/null
+++ b/app/react/docker/proxy/queries/nodes/useNodes.ts
@@ -0,0 +1,21 @@
+import { Node } from 'docker-types/generated/1.41';
+import { useQuery } from 'react-query';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { buildUrl } from './build-url';
+import { queryKeys } from './query-keys';
+
+export function useNodes(environmentId: EnvironmentId) {
+ return useQuery(queryKeys.base(environmentId), () => getNodes(environmentId));
+}
+
+async function getNodes(environmentId: EnvironmentId) {
+ try {
+ const { data } = await axios.get>(buildUrl(environmentId));
+ return data;
+ } catch (error) {
+ throw parseAxiosError(error, 'Unable to retrieve nodes');
+ }
+}
diff --git a/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/TasksDatatable.tsx b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/TasksDatatable.tsx
new file mode 100644
index 000000000..8f1e906fd
--- /dev/null
+++ b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/TasksDatatable.tsx
@@ -0,0 +1,21 @@
+import { NestedDatatable } from '@@/datatables/NestedDatatable';
+
+import { columns } from './columns';
+import { DecoratedTask } from './types';
+
+export function TasksDatatable({
+ dataset,
+ search,
+}: {
+ dataset: DecoratedTask[];
+ search?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/actions.tsx b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/actions.tsx
new file mode 100644
index 000000000..fe672dc74
--- /dev/null
+++ b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/actions.tsx
@@ -0,0 +1,45 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
+import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
+import { isAgentEnvironment } from '@/react/portainer/environments/utils';
+import { QuickActionsState } from '@/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions';
+import { TaskTableQuickActions } from '@/react/docker/services/common/TaskTableQuickActions';
+
+import { DecoratedTask } from '../types';
+
+import { columnHelper } from './helper';
+
+export const actions = columnHelper.display({
+ header: 'Actions',
+ cell: Cell,
+});
+
+function Cell({
+ row: { original: item },
+}: CellContext) {
+ const environmentQuery = useCurrentEnvironment();
+
+ if (!environmentQuery.data) {
+ return null;
+ }
+ const state: QuickActionsState = {
+ showQuickActionAttach: true,
+ showQuickActionExec: true,
+ showQuickActionInspect: true,
+ showQuickActionLogs: true,
+ showQuickActionStats: true,
+ };
+ const isAgent = isAgentEnvironment(environmentQuery.data.Type);
+
+ return isAgent && item.Container ? (
+
+ ) : (
+
+ );
+}
diff --git a/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/helper.ts b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/helper.ts
new file mode 100644
index 000000000..34343b077
--- /dev/null
+++ b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/helper.ts
@@ -0,0 +1,5 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { DecoratedTask } from '../types';
+
+export const columnHelper = createColumnHelper();
diff --git a/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/index.ts b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/index.ts
new file mode 100644
index 000000000..aaca2fa3f
--- /dev/null
+++ b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/index.ts
@@ -0,0 +1,19 @@
+import { isoDate } from '@/portainer/filters/filters';
+
+import { actions } from './actions';
+import { columnHelper } from './helper';
+import { node } from './node';
+import { status } from './status';
+import { task } from './task';
+
+export const columns = [
+ status,
+ task,
+ actions,
+ columnHelper.accessor((item) => item.Slot || '-', { header: 'Slot' }),
+ node,
+ columnHelper.accessor('Updated', {
+ header: 'Last Update',
+ cell: ({ getValue }) => isoDate(getValue()),
+ }),
+];
diff --git a/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/node.tsx b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/node.tsx
new file mode 100644
index 000000000..56ae02877
--- /dev/null
+++ b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/node.tsx
@@ -0,0 +1,32 @@
+import { Node } from 'docker-types/generated/1.41';
+import { CellContext } from '@tanstack/react-table';
+
+import { useNodes } from '@/react/docker/proxy/queries/nodes/useNodes';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { DecoratedTask } from '../types';
+
+import { columnHelper } from './helper';
+
+export const node = columnHelper.accessor('NodeId', {
+ header: 'Node',
+ cell: Cell,
+});
+
+function Cell({ getValue }: CellContext) {
+ const environmentId = useEnvironmentId();
+
+ const nodesQuery = useNodes(environmentId);
+
+ const nodes = nodesQuery.data || [];
+ return getNodeName(getValue(), nodes);
+}
+
+function getNodeName(nodeId: string, nodes: Array) {
+ const node = nodes.find((node) => node.ID === nodeId);
+ if (node?.Description?.Hostname) {
+ return node.Description.Hostname;
+ }
+
+ return '';
+}
diff --git a/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/status.tsx b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/status.tsx
new file mode 100644
index 000000000..7f1bc39ea
--- /dev/null
+++ b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/status.tsx
@@ -0,0 +1,27 @@
+import clsx from 'clsx';
+
+import { taskStatusBadge } from '@/docker/filters/utils';
+
+import { multiple } from '@@/datatables/filter-types';
+import { filterHOC } from '@@/datatables/Filter';
+
+import { columnHelper } from './helper';
+
+export const status = columnHelper.accessor((item) => item.Status?.State, {
+ header: 'Status',
+ enableColumnFilter: true,
+ filterFn: multiple,
+ meta: {
+ filter: filterHOC('Filter by state'),
+ width: 100,
+ },
+ cell({ getValue }) {
+ const value = getValue();
+
+ return (
+
+ {value}
+
+ );
+ },
+});
diff --git a/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/task.tsx b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/task.tsx
new file mode 100644
index 000000000..1a94f5947
--- /dev/null
+++ b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/columns/task.tsx
@@ -0,0 +1,47 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
+import { isAgentEnvironment } from '@/react/portainer/environments/utils';
+
+import { Link } from '@@/Link';
+
+import { DecoratedTask } from '../types';
+
+import { columnHelper } from './helper';
+
+export const task = columnHelper.accessor('Id', {
+ header: 'Task',
+ cell: Cell,
+});
+
+function Cell({
+ getValue,
+ row: { original: item },
+}: CellContext) {
+ const environmentQuery = useCurrentEnvironment();
+
+ if (!environmentQuery.data) {
+ return null;
+ }
+
+ const value = getValue();
+ const isAgent = isAgentEnvironment(environmentQuery.data.Type);
+
+ return isAgent && item.Container ? (
+
+ {value}
+
+ ) : (
+
+ {value}
+
+ );
+}
diff --git a/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/index.ts b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/index.ts
new file mode 100644
index 000000000..f95acf3ac
--- /dev/null
+++ b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/index.ts
@@ -0,0 +1 @@
+export { TasksDatatable } from './TasksDatatable';
diff --git a/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/types.ts b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/types.ts
new file mode 100644
index 000000000..fdff024a9
--- /dev/null
+++ b/app/react/docker/services/ListView/ServicesDatatable/TasksDatatable/types.ts
@@ -0,0 +1,6 @@
+import { TaskViewModel } from '@/docker/models/task';
+import { DockerContainer } from '@/react/docker/containers/types';
+
+export type DecoratedTask = TaskViewModel & {
+ Container?: DockerContainer;
+};
diff --git a/app/react/docker/services/common/TaskTableQuickActions.tsx b/app/react/docker/services/common/TaskTableQuickActions.tsx
new file mode 100644
index 000000000..367c4bc27
--- /dev/null
+++ b/app/react/docker/services/common/TaskTableQuickActions.tsx
@@ -0,0 +1,46 @@
+import { FileText, Info } from 'lucide-react';
+
+import { Authorized } from '@/react/hooks/useUser';
+
+import { Icon } from '@@/Icon';
+import { Link } from '@@/Link';
+
+interface State {
+ showQuickActionInspect: boolean;
+ showQuickActionLogs: boolean;
+}
+
+export function TaskTableQuickActions({
+ taskId,
+ state = {
+ showQuickActionInspect: true,
+ showQuickActionLogs: true,
+ },
+}: {
+ taskId: string;
+ state?: State;
+}) {
+ return (
+
+ {state.showQuickActionLogs && (
+
+
+
+
+
+ )}
+
+ {state.showQuickActionInspect && (
+
+
+
+
+
+ )}
+
+ );
+}
|