diff --git a/app/assets/ico/icon_updates-unknown.svg b/app/assets/ico/icon_updates-unknown.svg
new file mode 100644
index 000000000..d64bb3676
--- /dev/null
+++ b/app/assets/ico/icon_updates-unknown.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/docker/models/secret.ts b/app/docker/models/secret.ts
index 8473d8df5..5da084355 100644
--- a/app/docker/models/secret.ts
+++ b/app/docker/models/secret.ts
@@ -2,7 +2,7 @@ import { Secret } from 'docker-types/generated/1.41';
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
import { PortainerMetadata } from '@/react/docker/types';
-import { IResource } from '@/react/docker/components/datatable-helpers/createOwnershipColumn';
+import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn';
export class SecretViewModel implements IResource {
Id: string;
diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts
index 1edeeb660..0f6bf847f 100644
--- a/app/docker/react/components/index.ts
+++ b/app/docker/react/components/index.ts
@@ -23,6 +23,7 @@ import { AgentVolumeBrowser } from '@/react/docker/volumes/BrowseView/AgentVolum
import { ProcessesDatatable } from '@/react/docker/containers/StatsView/ProcessesDatatable';
import { ScaleServiceButton } from '@/react/docker/services/ListView/ServicesDatatable/columns/schedulingMode/ScaleServiceButton';
import { SecretsDatatable } from '@/react/docker/secrets/ListView/SecretsDatatable';
+import { StacksDatatable } from '@/react/docker/stacks/ListView/StacksDatatable';
import { containersModule } from './containers';
import { servicesModule } from './services';
@@ -140,6 +141,14 @@ const ngModule = angular
.component(
'dockerSecretsDatatable',
r2a(withUIRouter(SecretsDatatable), ['dataset', 'onRefresh', 'onRemove'])
+ )
+ .component(
+ 'dockerStacksDatatable',
+ r2a(withUIRouter(withCurrentUser(StacksDatatable)), [
+ 'dataset',
+ 'isImageNotificationEnabled',
+ 'onReload',
+ 'onRemove',
+ ])
);
-
export const componentsModule = ngModule.name;
diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html
deleted file mode 100644
index b30adf85d..000000000
--- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html
+++ /dev/null
@@ -1,262 +0,0 @@
-
diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.js b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.js
deleted file mode 100644
index 0680080c9..000000000
--- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.js
+++ /dev/null
@@ -1,15 +0,0 @@
-angular.module('portainer.app').component('stacksDatatable', {
- templateUrl: './stacksDatatable.html',
- controller: 'StacksDatatableController',
- bindings: {
- titleText: '@',
- titleIcon: '@',
- dataset: '<',
- tableKey: '@',
- orderBy: '@',
- reverseOrder: '<',
- removeAction: '<',
- refreshCallback: '<',
- createEnabled: '<',
- },
-});
diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js b/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js
deleted file mode 100644
index e531891bf..000000000
--- a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js
+++ /dev/null
@@ -1,108 +0,0 @@
-angular.module('portainer.app').controller('StacksDatatableController', [
- '$scope',
- '$controller',
- 'DatatableService',
- 'Authentication',
- function ($scope, $controller, DatatableService, Authentication) {
- angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
-
- this.filters = {
- state: {
- open: false,
- enabled: false,
- showActiveStacks: true,
- showUnactiveStacks: true,
- },
- };
-
- this.columnVisibility = {
- state: {
- open: false,
- },
- columns: {
- updated: {
- label: 'Updated',
- display: false,
- },
- },
- };
-
- this.onColumnVisibilityChange = onColumnVisibilityChange.bind(this);
- function onColumnVisibilityChange(columns) {
- this.columnVisibility.columns = columns;
- DatatableService.setColumnVisibilitySettings(this.tableKey, this.columnVisibility);
- }
-
- /**
- * Do not allow external items
- */
- this.allowSelection = function (item) {
- if (item.External) {
- return false;
- }
-
- return !(item.External && !this.isAdmin);
- };
-
- this.applyFilters = applyFilters.bind(this);
- function applyFilters(stack) {
- const { showActiveStacks, showUnactiveStacks } = this.filters.state;
- if (stack.Orphaned) {
- return stack.OrphanedRunning || this.settings.allOrphanedStacks;
- } else {
- return (stack.Status === 1 && showActiveStacks) || (stack.Status === 2 && showUnactiveStacks) || stack.External || !stack.Status;
- }
- }
-
- this.onFilterChange = onFilterChange.bind(this);
- function onFilterChange() {
- const { showActiveStacks, showUnactiveStacks } = this.filters.state;
- this.filters.state.enabled = !showActiveStacks || !showUnactiveStacks;
- DatatableService.setDataTableFilters(this.tableKey, this.filters);
- }
-
- this.onSettingsAllOrphanedStacksChange = function () {
- DatatableService.setDataTableSettings(this.tableKey, this.settings);
- };
-
- this.$onInit = function () {
- this.isAdmin = Authentication.isAdmin();
- this.setDefaults();
- this.prepareTableFromDataset();
-
- this.state.orderBy = this.orderBy;
- var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
- if (storedOrder !== null) {
- this.state.reverseOrder = storedOrder.reverse;
- this.state.orderBy = storedOrder.orderBy;
- }
-
- var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
- if (textFilter !== null) {
- this.state.textFilter = textFilter;
- this.onTextFilterChange();
- }
-
- var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
- if (storedFilters !== null) {
- this.filters = storedFilters;
- }
- if (this.filters && this.filters.state) {
- this.filters.state.open = false;
- }
-
- var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
- if (storedSettings !== null) {
- this.settings = storedSettings;
- this.settings.open = false;
- this.settings.allOrphanedStacks = this.settings.allOrphanedStacks && this.isAdmin;
- }
- this.onSettingsRepeaterChange();
-
- var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey);
- if (storedColumnVisibility !== null) {
- this.columnVisibility = storedColumnVisibility;
- }
- };
- },
-]);
diff --git a/app/portainer/helpers/stackHelper.js b/app/portainer/helpers/stackHelper.js
index 63e959e84..57ba8f710 100644
--- a/app/portainer/helpers/stackHelper.js
+++ b/app/portainer/helpers/stackHelper.js
@@ -1,7 +1,7 @@
import _ from 'lodash-es';
import YAML from 'yaml';
import GenericHelper from '@/portainer/helpers/genericHelper';
-import { ExternalStackViewModel } from '@/portainer/models/stack';
+import { ExternalStackViewModel } from '@/react/docker/stacks/view-models/external-stack';
angular.module('portainer.app').factory('StackHelper', [
function StackHelperFactory() {
diff --git a/app/portainer/models/stack.js b/app/portainer/models/stack.js
deleted file mode 100644
index 9b5141d1c..000000000
--- a/app/portainer/models/stack.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
-
-export function StackViewModel(data) {
- this.Id = data.Id;
- this.Type = data.Type;
- this.Name = data.Name;
- this.EndpointId = data.EndpointId;
- this.SwarmId = data.SwarmId;
- this.Env = data.Env ? data.Env : [];
- this.Option = data.Option;
- this.IsComposeFormat = data.IsComposeFormat;
- if (data.ResourceControl && data.ResourceControl.Id !== 0) {
- this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
- }
- this.Status = data.Status;
- this.CreationDate = data.CreationDate;
- this.CreatedBy = data.CreatedBy;
- this.UpdateDate = data.UpdateDate;
- this.UpdatedBy = data.UpdatedBy;
- this.Regular = true;
- this.External = false;
- this.Orphaned = false;
- this.Checked = false;
- this.GitConfig = data.GitConfig;
- this.FromAppTemplate = data.FromAppTemplate;
- this.AdditionalFiles = data.AdditionalFiles;
- this.AutoUpdate = data.AutoUpdate;
-}
-
-export function ExternalStackViewModel(name, type, creationDate) {
- this.Name = name;
- this.Type = type;
- this.CreationDate = creationDate;
-
- this.Regular = false;
- this.External = true;
- this.Orphaned = false;
- this.Checked = false;
-}
-
-export function OrphanedStackViewModel(data) {
- this.Id = data.Id;
- this.Type = data.Type;
- this.Name = data.Name;
- this.EndpointId = data.EndpointId;
- this.SwarmId = data.SwarmId;
- this.Env = data.Env ? data.Env : [];
- this.Option = data.Option;
- if (data.ResourceControl && data.ResourceControl.Id !== 0) {
- this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
- }
- this.Status = data.Status;
- this.CreationDate = data.CreationDate;
- this.CreatedBy = data.CreatedBy;
- this.UpdateDate = data.UpdateDate;
- this.UpdatedBy = data.UpdatedBy;
-
- this.Regular = false;
- this.External = false;
- this.Orphaned = true;
- this.OrphanedRunning = false;
- this.Checked = false;
-}
diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js
index 5adfd4b49..dbb92ae90 100644
--- a/app/portainer/services/api/stackService.js
+++ b/app/portainer/services/api/stackService.js
@@ -1,6 +1,6 @@
import _ from 'lodash-es';
import { transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
-import { StackViewModel, OrphanedStackViewModel } from '../../models/stack';
+import { StackViewModel } from '@/react/docker/stacks/view-models/stack';
angular.module('portainer.app').factory('StackService', [
'$q',
@@ -164,11 +164,7 @@ angular.module('portainer.app').factory('StackService', [
})
.then(function success(data) {
var stacks = data.stacks.map(function (item) {
- if (item.EndpointId == endpointId) {
- return new StackViewModel(item);
- } else {
- return new OrphanedStackViewModel(item);
- }
+ return new StackViewModel(item, item.EndpointId == endpointId);
});
var externalStacks = data.externalStacks;
@@ -197,11 +193,7 @@ angular.module('portainer.app').factory('StackService', [
})
.then(function success(data) {
var stacks = data.stacks.map(function (item) {
- if (item.EndpointId == endpointId) {
- return new StackViewModel(item);
- } else {
- return new OrphanedStackViewModel(item);
- }
+ return new StackViewModel(item, item.EndpointId == endpointId);
});
var externalStacks = data.externalStacks;
diff --git a/app/portainer/views/stacks/stacks.html b/app/portainer/views/stacks/stacks.html
index 27a9ad7e1..a0ebe4585 100644
--- a/app/portainer/views/stacks/stacks.html
+++ b/app/portainer/views/stacks/stacks.html
@@ -1,16 +1,9 @@
-
+
diff --git a/app/react/common/stacks/types.ts b/app/react/common/stacks/types.ts
index 953702918..b2d79bdd4 100644
--- a/app/react/common/stacks/types.ts
+++ b/app/react/common/stacks/types.ts
@@ -1,3 +1,9 @@
+import { ResourceControlResponse } from '@/react/portainer/access-control/types';
+import {
+ AutoUpdateResponse,
+ RepoConfigResponse,
+} from '@/react/portainer/gitops/types';
+
export type StackId = number;
export enum StackType {
@@ -24,6 +30,41 @@ export enum StackStatus {
Inactive,
}
+export interface Stack {
+ Id: number;
+ Name: string;
+ Type: StackType;
+ EndpointID: number;
+ SwarmID: string;
+ EntryPoint: string;
+ Env: {
+ name: string;
+ value: string;
+ }[];
+ ResourceControl?: ResourceControlResponse;
+ Status: StackStatus;
+ ProjectPath: string;
+ CreationDate: number;
+ CreatedBy: string;
+ UpdateDate: number;
+ UpdatedBy: string;
+ AdditionalFiles?: string[];
+ AutoUpdate?: AutoUpdateResponse;
+ Option?: {
+ Prune: boolean;
+ Force: boolean;
+ };
+ GitConfig?: RepoConfigResponse;
+ FromAppTemplate: boolean;
+ Namespace?: string;
+ IsComposeFormat: boolean;
+ Webhook?: string;
+ SupportRelativePath: boolean;
+ FilesystemPath: string;
+ StackFileVersion: string;
+ PreviousDeploymentInfo: unknown;
+}
+
export type StackFile = {
StackFileContent: string;
};
diff --git a/app/react/components/datatables/TableHeaderCell.tsx b/app/react/components/datatables/TableHeaderCell.tsx
index a2e8c014e..3624b41a2 100644
--- a/app/react/components/datatables/TableHeaderCell.tsx
+++ b/app/react/components/datatables/TableHeaderCell.tsx
@@ -1,7 +1,6 @@
import clsx from 'clsx';
import { CSSProperties, PropsWithChildren, ReactNode } from 'react';
-import styles from './TableHeaderCell.module.css';
import { TableHeaderSortIcons } from './TableHeaderSortIcons';
interface Props {
@@ -67,8 +66,7 @@ function SortWrapper({
onClick={() => onClick(!isSortedDesc)}
className={clsx(
'!ml-0 h-full border-none !bg-transparent !px-0 focus:border-none',
- styles.sortable,
- isSorted && styles.sortingActive
+ !isSorted && 'group'
)}
>
@@ -82,7 +80,7 @@ function SortWrapper({
);
}
-interface TableColumnHeaderAngularProps {
+export interface TableColumnHeaderAngularProps {
colTitle: string;
canSort: boolean;
isSorted?: boolean;
@@ -94,7 +92,8 @@ export function TableColumnHeaderAngular({
isSorted,
colTitle,
isSortedDesc = true,
-}: TableColumnHeaderAngularProps) {
+ children,
+}: PropsWithChildren
) {
return (
{colTitle}
+ {children}
);
diff --git a/app/react/components/datatables/extend-options/withGlobalFilter.ts b/app/react/components/datatables/extend-options/withGlobalFilter.ts
index 22090838e..cce2ec24b 100644
--- a/app/react/components/datatables/extend-options/withGlobalFilter.ts
+++ b/app/react/components/datatables/extend-options/withGlobalFilter.ts
@@ -3,9 +3,12 @@ import { TableOptions } from '@tanstack/react-table';
import { defaultGlobalFilterFn } from '../Datatable';
import { DefaultType } from '../types';
-export function withGlobalFilter(
- filterFn: typeof defaultGlobalFilterFn
-) {
+export function withGlobalFilter<
+ D extends DefaultType,
+ TFilter extends {
+ search: string;
+ },
+>(filterFn: typeof defaultGlobalFilterFn) {
return function extendOptions(options: TableOptions) {
return {
...options,
diff --git a/app/react/components/datatables/useRepeater.ts b/app/react/components/datatables/useRepeater.ts
index f2fb7d8d6..03a8c4661 100644
--- a/app/react/components/datatables/useRepeater.ts
+++ b/app/react/components/datatables/useRepeater.ts
@@ -2,7 +2,7 @@ import { useEffect, useCallback, useState } from 'react';
export function useRepeater(
refreshRate: number,
- onRefresh?: () => Promise
+ onRefresh?: () => Promise | void
) {
const [intervalId, setIntervalId] = useState(null);
diff --git a/app/react/docker/components/ImageStatus/ImageStatus.tsx b/app/react/docker/components/ImageStatus/ImageStatus.tsx
new file mode 100644
index 000000000..eabc8213c
--- /dev/null
+++ b/app/react/docker/components/ImageStatus/ImageStatus.tsx
@@ -0,0 +1,85 @@
+import { useQuery } from 'react-query';
+import { Loader } from 'lucide-react';
+
+import {
+ getContainerImagesStatus,
+ getServiceImagesStatus,
+} from '@/react/docker/images/image.service';
+import { useEnvironment } from '@/react/portainer/environments/queries';
+import { statusIcon } from '@/react/docker/components/ImageStatus/helpers';
+import { ResourceID, ResourceType } from '@/react/docker/images/types';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { Icon } from '@@/Icon';
+
+export interface Props {
+ environmentId: EnvironmentId;
+ resourceId: ResourceID;
+ resourceType?: ResourceType;
+ nodeName?: string;
+}
+
+export function ImageStatus({
+ environmentId,
+ resourceId,
+ resourceType = ResourceType.CONTAINER,
+ nodeName = '',
+}: Props) {
+ const enableImageNotificationQuery = useEnvironment(
+ environmentId,
+ (environment) => environment?.EnableImageNotification
+ );
+
+ const { data, isLoading, isError } = useImageNotification(
+ environmentId,
+ resourceId,
+ resourceType,
+ nodeName,
+ enableImageNotificationQuery.data
+ );
+
+ if (!enableImageNotificationQuery.data || isError) {
+ return null;
+ }
+
+ if (isLoading || !data) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export function useImageNotification(
+ environmentId: number,
+ resourceId: ResourceID,
+ resourceType: ResourceType,
+ nodeName: string,
+ enabled = false
+) {
+ return useQuery(
+ [
+ 'environments',
+ environmentId,
+ 'docker',
+ 'images',
+ resourceType,
+ resourceId,
+ 'status',
+ ],
+ () =>
+ resourceType === ResourceType.SERVICE
+ ? getServiceImagesStatus(environmentId, resourceId)
+ : getContainerImagesStatus(environmentId, resourceId, nodeName),
+ {
+ enabled,
+ }
+ );
+}
diff --git a/app/react/docker/components/ImageStatus/helpers.ts b/app/react/docker/components/ImageStatus/helpers.ts
new file mode 100644
index 000000000..bc7238967
--- /dev/null
+++ b/app/react/docker/components/ImageStatus/helpers.ts
@@ -0,0 +1,20 @@
+import { Loader } from 'lucide-react';
+
+import UpdatesAvailable from '@/assets/ico/icon_updates-available.svg?c';
+import UpToDate from '@/assets/ico/icon_up-to-date.svg?c';
+import UpdatesUnknown from '@/assets/ico/icon_updates-unknown.svg?c';
+
+import { ImageStatus } from '../../images/types';
+
+export function statusIcon(status: ImageStatus) {
+ switch (status.Status) {
+ case 'outdated':
+ return UpdatesAvailable;
+ case 'updated':
+ return UpToDate;
+ case 'processing':
+ return Loader;
+ default:
+ return UpdatesUnknown;
+ }
+}
diff --git a/app/react/docker/components/ImageStatus/index.js b/app/react/docker/components/ImageStatus/index.js
new file mode 100644
index 000000000..a2045252b
--- /dev/null
+++ b/app/react/docker/components/ImageStatus/index.js
@@ -0,0 +1 @@
+export { ImageStatus } from './ImageStatus';
diff --git a/app/react/docker/components/datatable/TableColumnHeaderImageUpToDate.tsx b/app/react/docker/components/datatable/TableColumnHeaderImageUpToDate.tsx
new file mode 100644
index 000000000..670bf2525
--- /dev/null
+++ b/app/react/docker/components/datatable/TableColumnHeaderImageUpToDate.tsx
@@ -0,0 +1,65 @@
+import UpdatesAvailable from '@/assets/ico/icon_updates-available.svg?c';
+import UpToDate from '@/assets/ico/icon_up-to-date.svg?c';
+import UpdatesUnknown from '@/assets/ico/icon_updates-unknown.svg?c';
+import { useEnvironment } from '@/react/portainer/environments/queries';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { Icon } from '@@/Icon';
+import { Tooltip } from '@@/Tip/Tooltip';
+import {
+ TableColumnHeaderAngular,
+ TableColumnHeaderAngularProps,
+} from '@@/datatables/TableHeaderCell';
+
+export function TableColumnHeaderImageUpToDate({
+ canSort,
+ isSorted,
+ colTitle,
+ isSortedDesc = true,
+}: TableColumnHeaderAngularProps) {
+ return (
+
+
+
+ );
+}
+
+export function ImageUpToDateTooltip() {
+ const environmentId = useEnvironmentId();
+
+ const enableImageNotificationQuery = useEnvironment(
+ environmentId,
+ (environment) => environment?.EnableImageNotification
+ );
+
+ if (!enableImageNotificationQuery.data) {
+ return null;
+ }
+
+ return (
+
+
+
+ Images are up to date
+
+
+
+ Updates are available
+
+
+
+ Updates availability unknown
+
+
+ }
+ />
+ );
+}
diff --git a/app/react/docker/components/datatable-helpers/createOwnershipColumn.tsx b/app/react/docker/components/datatable/createOwnershipColumn.tsx
similarity index 85%
rename from app/react/docker/components/datatable-helpers/createOwnershipColumn.tsx
rename to app/react/docker/components/datatable/createOwnershipColumn.tsx
index 811fcd201..3a4b70d6e 100644
--- a/app/react/docker/components/datatable-helpers/createOwnershipColumn.tsx
+++ b/app/react/docker/components/datatable/createOwnershipColumn.tsx
@@ -11,16 +11,16 @@ export interface IResource {
};
}
-export function createOwnershipColumn(): ColumnDef<
- D,
- ResourceControlOwnership
-> {
+export function createOwnershipColumn(
+ enableHiding = true
+): ColumnDef {
return {
accessorFn: (row) =>
row.ResourceControl?.Ownership || ResourceControlOwnership.ADMINISTRATORS,
header: 'Ownership',
id: 'ownership',
cell: OwnershipCell,
+ enableHiding,
};
function OwnershipCell({
diff --git a/app/react/docker/configs/ListView/ConfigsDatatable/columns.tsx b/app/react/docker/configs/ListView/ConfigsDatatable/columns.tsx
index 5a3445332..63c086d8f 100644
--- a/app/react/docker/configs/ListView/ConfigsDatatable/columns.tsx
+++ b/app/react/docker/configs/ListView/ConfigsDatatable/columns.tsx
@@ -1,7 +1,7 @@
import { createColumnHelper } from '@tanstack/react-table';
import { isoDate } from '@/portainer/filters/filters';
-import { createOwnershipColumn } from '@/react/docker/components/datatable-helpers/createOwnershipColumn';
+import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn';
import { buildNameColumn } from '@@/datatables/buildNameColumn';
diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx
index 45e4e730a..b1ec5bb69 100644
--- a/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx
+++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx
@@ -1,7 +1,7 @@
import _ from 'lodash';
import { useMemo } from 'react';
-import { createOwnershipColumn } from '@/react/docker/components/datatable-helpers/createOwnershipColumn';
+import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn';
import { DockerContainer } from '@/react/docker/containers/types';
import { created } from './created';
diff --git a/app/react/docker/images/.keep b/app/react/docker/images/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/docker/images/image.service.ts b/app/react/docker/images/image.service.ts
new file mode 100644
index 000000000..51f5e3d71
--- /dev/null
+++ b/app/react/docker/images/image.service.ts
@@ -0,0 +1,47 @@
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import axios from '@/portainer/services/axios';
+import { ServiceId } from '@/react/docker/services/types';
+
+import { ContainerId } from '../containers/types';
+
+import { ImageStatus } from './types';
+
+export async function getContainerImagesStatus(
+ environmentId: EnvironmentId,
+ containerID: ContainerId,
+ nodeName: string
+) {
+ try {
+ let headers = {};
+ if (nodeName !== '') {
+ headers = { 'X-PortainerAgent-Target': nodeName };
+ }
+ const { data } = await axios.get(
+ `/docker/${environmentId}/containers/${containerID}/image_status`,
+ { headers }
+ );
+ return data;
+ } catch (e) {
+ return {
+ Status: 'unknown',
+ Message: `Unable to retrieve image status for container: ${containerID}`,
+ };
+ }
+}
+
+export async function getServiceImagesStatus(
+ environmentId: EnvironmentId,
+ serviceID: ServiceId
+) {
+ try {
+ const { data } = await axios.get(
+ `/docker/${environmentId}/services/${serviceID}/image_status`
+ );
+ return data;
+ } catch (e) {
+ return {
+ Status: 'unknown',
+ Message: `Unable to retrieve image status for service: ${serviceID}`,
+ };
+ }
+}
diff --git a/app/react/docker/secrets/ListView/SecretsDatatable.tsx b/app/react/docker/secrets/ListView/SecretsDatatable.tsx
index 7b05d0c45..58a70321c 100644
--- a/app/react/docker/secrets/ListView/SecretsDatatable.tsx
+++ b/app/react/docker/secrets/ListView/SecretsDatatable.tsx
@@ -18,7 +18,7 @@ import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { useRepeater } from '@@/datatables/useRepeater';
-import { createOwnershipColumn } from '../../components/datatable-helpers/createOwnershipColumn';
+import { createOwnershipColumn } from '../../components/datatable/createOwnershipColumn';
const columnHelper = createColumnHelper();
diff --git a/app/react/docker/services/.keep b/app/react/docker/services/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/docker/services/types.ts b/app/react/docker/services/types.ts
new file mode 100644
index 000000000..fc37f13b0
--- /dev/null
+++ b/app/react/docker/services/types.ts
@@ -0,0 +1,16 @@
+export type ServiceId = string;
+
+export interface DockerServiceResponse {
+ ID: string;
+ Spec: {
+ Name: string;
+ };
+}
+
+export type ServiceLogsParams = {
+ stdout?: boolean;
+ stderr?: boolean;
+ timestamps?: boolean;
+ since?: number;
+ tail?: number;
+};
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/StacksDatatable.tsx b/app/react/docker/stacks/ListView/StacksDatatable/StacksDatatable.tsx
new file mode 100644
index 000000000..800e5f478
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/StacksDatatable.tsx
@@ -0,0 +1,113 @@
+import { Layers } from 'lucide-react';
+import { Row } from '@tanstack/react-table';
+
+import { useAuthorizations, useCurrentUser } from '@/react/hooks/useUser';
+import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
+
+import { Datatable } from '@@/datatables';
+import { useTableState } from '@@/datatables/useTableState';
+import { useRepeater } from '@@/datatables/useRepeater';
+import { defaultGlobalFilterFn } from '@@/datatables/Datatable';
+import { withGlobalFilter } from '@@/datatables/extend-options/withGlobalFilter';
+
+import { isExternalStack, isOrphanedStack } from '../../view-models/utils';
+
+import { TableActions } from './TableActions';
+import { TableSettingsMenus } from './TableSettingsMenus';
+import { createStore } from './store';
+import { useColumns } from './columns';
+import { DecoratedStack } from './types';
+
+const tableKey = 'docker_stacks';
+const settingsStore = createStore(tableKey);
+
+export function StacksDatatable({
+ onRemove,
+ onReload,
+ isImageNotificationEnabled,
+ dataset,
+}: {
+ onRemove: (items: Array) => void;
+ onReload: () => void;
+ isImageNotificationEnabled: boolean;
+ dataset: Array;
+}) {
+ const tableState = useTableState(settingsStore, tableKey);
+ useRepeater(tableState.autoRefreshRate, onReload);
+ const { isAdmin } = useCurrentUser();
+ const canManageStacks = useAuthorizations([
+ 'PortainerStackCreate',
+ 'PortainerStackDelete',
+ ]);
+ const columns = useColumns(isImageNotificationEnabled);
+
+ return (
+
+ settingsManager={tableState}
+ title="Stacks"
+ titleIcon={Layers}
+ renderTableActions={(selectedRows) => (
+
+ )}
+ renderTableSettings={(tableInstance) => (
+
+ )}
+ columns={columns}
+ dataset={dataset}
+ isRowSelectable={({ original: item }) =>
+ allowSelection(item, isAdmin, canManageStacks)
+ }
+ getRowId={(item) => item.Id.toString()}
+ initialTableState={{
+ globalFilter: {
+ showOrphanedStacks: tableState.showOrphanedStacks,
+ },
+ columnVisibility: Object.fromEntries(
+ tableState.hiddenColumns.map((col) => [col, false])
+ ),
+ }}
+ extendTableOptions={withGlobalFilter(globalFilterFn)}
+ />
+ );
+}
+
+function allowSelection(
+ item: DecoratedStack,
+ isAdmin: boolean,
+ canManageStacks: boolean
+) {
+ if (isExternalStack(item)) {
+ return false;
+ }
+
+ if (isBE && isOrphanedStack(item) && !isAdmin) {
+ return false;
+ }
+
+ return isAdmin || canManageStacks;
+}
+
+function globalFilterFn(
+ row: Row,
+ columnId: string,
+ filterValue: null | { showOrphanedStacks: boolean; search: string }
+) {
+ return (
+ orphanedFilter(row, filterValue) &&
+ defaultGlobalFilterFn(row, columnId, filterValue)
+ );
+}
+
+function orphanedFilter(
+ row: Row,
+ filterValue: null | { showOrphanedStacks: boolean; search: string }
+) {
+ if (filterValue?.showOrphanedStacks) {
+ return true;
+ }
+
+ return !isOrphanedStack(row.original);
+}
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/TableActions.tsx b/app/react/docker/stacks/ListView/StacksDatatable/TableActions.tsx
new file mode 100644
index 000000000..f12542b3d
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/TableActions.tsx
@@ -0,0 +1,45 @@
+import { Trash2, Plus } from 'lucide-react';
+
+import { Authorized } from '@/react/hooks/useUser';
+
+import { Link } from '@@/Link';
+import { Button } from '@@/buttons';
+
+import { DecoratedStack } from './types';
+
+export function TableActions({
+ selectedItems,
+ onRemove,
+}: {
+ selectedItems: Array;
+ onRemove: (items: Array) => void;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/TableSettingsMenus.tsx b/app/react/docker/stacks/ListView/StacksDatatable/TableSettingsMenus.tsx
new file mode 100644
index 000000000..1d0aed335
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/TableSettingsMenus.tsx
@@ -0,0 +1,61 @@
+import { Table } from '@tanstack/react-table';
+
+import { Authorized } from '@/react/hooks/useUser';
+import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
+
+import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
+import { TableSettingsMenu } from '@@/datatables';
+import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
+import { Checkbox } from '@@/form-components/Checkbox';
+
+import { TableSettings } from './store';
+import { DecoratedStack } from './types';
+
+export function TableSettingsMenus({
+ tableInstance,
+ tableState,
+}: {
+ tableInstance: Table;
+ tableState: TableSettings;
+}) {
+ const columnsToHide = tableInstance
+ .getAllColumns()
+ .filter((col) => col.getCanHide());
+
+ return (
+ <>
+
+ columns={columnsToHide}
+ onChange={(hiddenColumns) => {
+ tableState.setHiddenColumns(hiddenColumns);
+ tableInstance.setColumnVisibility(
+ Object.fromEntries(hiddenColumns.map((col) => [col, false]))
+ );
+ }}
+ value={tableState.hiddenColumns}
+ />
+
+ {isBE && (
+
+ {
+ tableState.setShowOrphanedStacks(e.target.checked);
+ tableInstance.setGlobalFilter((filter: object) => ({
+ ...filter,
+ showOrphanedStacks: e.target.checked,
+ }));
+ }}
+ />
+
+ )}
+ tableState.setAutoRefreshRate(value)}
+ />
+
+ >
+ );
+}
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/StackImageStatus.tsx b/app/react/docker/stacks/ListView/StacksDatatable/columns/StackImageStatus.tsx
new file mode 100644
index 000000000..e08c5a865
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/StackImageStatus.tsx
@@ -0,0 +1,56 @@
+import { useQuery } from 'react-query';
+import { Loader2 } from 'lucide-react';
+
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { useEnvironment } from '@/react/portainer/environments/queries';
+import { statusIcon } from '@/react/docker/components/ImageStatus/helpers';
+
+import { Icon } from '@@/Icon';
+
+import { getStackImagesStatus } from './getStackImagesStatus';
+
+export interface Props {
+ stackId: number;
+ environmentId: number;
+}
+
+export function StackImageStatus({ stackId, environmentId }: Props) {
+ const { data, isLoading, isError } = useStackImageNotification(
+ stackId,
+ environmentId
+ );
+
+ if (isError) {
+ return null;
+ }
+
+ if (isLoading || !data) {
+ return (
+
+ );
+ }
+
+ return ;
+}
+
+export function useStackImageNotification(
+ stackId: number,
+ environmentId?: EnvironmentId
+) {
+ const enableImageNotificationQuery = useEnvironment(
+ environmentId,
+ (environment) => environment?.EnableImageNotification
+ );
+
+ return useQuery(
+ ['stacks', stackId, 'images', 'status'],
+ () => getStackImagesStatus(stackId),
+ {
+ enabled: enableImageNotificationQuery.data,
+ }
+ );
+}
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/control.tsx b/app/react/docker/stacks/ListView/StacksDatatable/columns/control.tsx
new file mode 100644
index 000000000..4c417be8d
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/control.tsx
@@ -0,0 +1,63 @@
+import { CellContext } from '@tanstack/react-table';
+import { AlertCircle } from 'lucide-react';
+import { PropsWithChildren } from 'react';
+
+import {
+ isExternalStack,
+ isOrphanedStack,
+ isRegularStack,
+} from '@/react/docker/stacks/view-models/utils';
+
+import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
+import { Icon } from '@@/Icon';
+
+import { DecoratedStack } from '../types';
+
+import { columnHelper } from './helper';
+
+export const control = columnHelper.display({
+ header: 'Control',
+ id: 'control',
+ cell: ControlCell,
+ enableHiding: false,
+});
+
+function ControlCell({
+ row: { original: item },
+}: CellContext) {
+ if (isRegularStack(item)) {
+ return <>Total>;
+ }
+
+ if (isExternalStack(item)) {
+ return (
+
+ Limited
+
+ );
+ }
+
+ if (isOrphanedStack(item)) {
+ return (
+
+ Orphaned
+
+ );
+ }
+
+ return null;
+}
+
+function Warning({
+ tooltip,
+ children,
+}: PropsWithChildren<{ tooltip: string }>) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/deployed-version.tsx b/app/react/docker/stacks/ListView/StacksDatatable/columns/deployed-version.tsx
new file mode 100644
index 000000000..1f974a976
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/deployed-version.tsx
@@ -0,0 +1,41 @@
+import { isExternalStack } from '@/react/docker/stacks/view-models/utils';
+
+import { columnHelper } from './helper';
+
+export const deployedVersion = columnHelper.accessor(
+ (item) => {
+ if (isExternalStack(item)) {
+ return '';
+ }
+
+ return item.GitConfig ? item.GitConfig.ConfigHash : item.StackFileVersion;
+ },
+ {
+ header: 'Deployed Version',
+ id: 'deployed-version',
+ cell: ({ row: { original: item } }) => {
+ if (isExternalStack(item)) {
+ return -
;
+ }
+
+ if (item.GitConfig) {
+ return (
+
+ );
+ }
+
+ return {item.StackFileVersion || '-'}
;
+ },
+ meta: {
+ className: '[&>*]:justify-center',
+ },
+ }
+);
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/getStackImagesStatus.ts b/app/react/docker/stacks/ListView/StacksDatatable/columns/getStackImagesStatus.ts
new file mode 100644
index 000000000..4362d62ba
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/getStackImagesStatus.ts
@@ -0,0 +1,16 @@
+import axios from '@/portainer/services/axios';
+import { ImageStatus } from '@/react/docker/images/types';
+
+export async function getStackImagesStatus(id: number) {
+ try {
+ const { data } = await axios.get(
+ `/stacks/${id}/images_status`
+ );
+ return data;
+ } catch (e) {
+ return {
+ Status: 'unknown',
+ Message: `Unable to retrieve image status for stack: ${id}`,
+ };
+ }
+}
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/helper.ts b/app/react/docker/stacks/ListView/StacksDatatable/columns/helper.ts
new file mode 100644
index 000000000..2cae65a9c
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/helper.ts
@@ -0,0 +1,5 @@
+import { createColumnHelper } from '@tanstack/react-table';
+
+import { DecoratedStack } from '../types';
+
+export const columnHelper = createColumnHelper();
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/image-notification.tsx b/app/react/docker/stacks/ListView/StacksDatatable/columns/image-notification.tsx
new file mode 100644
index 000000000..fca1fcb9a
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/image-notification.tsx
@@ -0,0 +1,34 @@
+import { CellContext } from '@tanstack/react-table';
+
+import { ImageUpToDateTooltip } from '@/react/docker/components/datatable/TableColumnHeaderImageUpToDate';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { isRegularStack } from '@/react/docker/stacks/view-models/utils';
+
+import { DecoratedStack } from '../types';
+
+import { StackImageStatus } from './StackImageStatus';
+import { columnHelper } from './helper';
+
+export const imageNotificationColumn = columnHelper.display({
+ id: 'imageNotification',
+ enableHiding: false,
+ header: () => (
+ <>
+ Images up to date
+
+ >
+ ),
+ cell: Cell,
+});
+
+function Cell({
+ row: { original: item },
+}: CellContext) {
+ const environmentId = useEnvironmentId();
+
+ if (!isRegularStack(item)) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/index.ts b/app/react/docker/stacks/ListView/StacksDatatable/columns/index.ts
new file mode 100644
index 000000000..85b97e7c1
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/index.ts
@@ -0,0 +1,58 @@
+import _ from 'lodash';
+
+import { StackType } from '@/react/common/stacks/types';
+import { isoDateFromTimestamp } from '@/portainer/filters/filters';
+import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn';
+
+import { DecoratedStack } from '../types';
+
+import { columnHelper } from './helper';
+import { name } from './name';
+import { imageNotificationColumn } from './image-notification';
+import { control } from './control';
+import { deployedVersion } from './deployed-version';
+
+export function useColumns(isImageNotificationEnabled: boolean) {
+ return _.compact([
+ name,
+ columnHelper.accessor(
+ (item) => (item.Type === StackType.DockerCompose ? 'Compose' : 'Swarm'),
+ {
+ id: 'type',
+ header: 'Type',
+ enableHiding: false,
+ }
+ ),
+ isImageNotificationEnabled && imageNotificationColumn,
+ control,
+ columnHelper.accessor('CreationDate', {
+ id: 'creationDate',
+ header: 'Created',
+ enableHiding: false,
+ cell: ({ getValue, row: { original: item } }) => {
+ const value = getValue();
+ if (!value) {
+ return '-';
+ }
+
+ const by = item.CreatedBy ? `by ${item.CreatedBy}` : '';
+ return `${isoDateFromTimestamp(value)} ${by}`.trim();
+ },
+ }),
+ columnHelper.accessor('UpdateDate', {
+ id: 'updateDate',
+ header: 'Updated',
+ cell: ({ getValue, row: { original: item } }) => {
+ const value = getValue();
+ if (!value) {
+ return '-';
+ }
+
+ const by = item.UpdatedBy ? `by ${item.UpdatedBy}` : '';
+ return `${isoDateFromTimestamp(value)} ${by}`.trim();
+ },
+ }),
+ deployedVersion,
+ createOwnershipColumn(false),
+ ]);
+}
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx b/app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx
new file mode 100644
index 000000000..58de9ae47
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx
@@ -0,0 +1,130 @@
+import { CellContext, Column } from '@tanstack/react-table';
+
+import { useCurrentUser } from '@/react/hooks/useUser';
+import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
+import { StackStatus } from '@/react/common/stacks/types';
+import {
+ isExternalStack,
+ isOrphanedStack,
+ isRegularStack,
+} from '@/react/docker/stacks/view-models/utils';
+
+import { Link } from '@@/Link';
+import { MultipleSelectionFilter } from '@@/datatables/Filter';
+
+import { DecoratedStack } from '../types';
+
+import { columnHelper } from './helper';
+
+const filterOptions = ['Active Stacks', 'Inactive Stacks'] as const;
+
+type FilterOption = (typeof filterOptions)[number];
+
+export const name = columnHelper.accessor('Name', {
+ header: 'Name',
+ id: 'name',
+ cell: NameCell,
+ enableHiding: false,
+ enableColumnFilter: true,
+ filterFn: (
+ { original: stack },
+ columnId,
+ filterValue: Array
+ ) => {
+ if (filterValue.length === 0) {
+ return true;
+ }
+
+ if (isExternalStack(stack) || !stack.Status) {
+ return true;
+ }
+
+ return (
+ (stack.Status === StackStatus.Active &&
+ filterValue.includes('Active Stacks')) ||
+ (stack.Status === StackStatus.Inactive &&
+ filterValue.includes('Inactive Stacks'))
+ );
+ },
+ meta: {
+ filter: Filter,
+ },
+});
+
+function NameCell({
+ row: { original: item },
+}: CellContext) {
+ return (
+ <>
+
+ {isRegularStack(item) && item.Status === 2 && (
+
+ Inactive
+
+ )}
+ >
+ );
+}
+
+function NameLink({ item }: { item: DecoratedStack }) {
+ const { isAdmin } = useCurrentUser();
+
+ const name = item.Name;
+
+ if (isExternalStack(item)) {
+ return (
+
+ {name}
+
+ );
+ }
+
+ if (!isAdmin && isOrphanedStack(item)) {
+ return <>{name}>;
+ }
+
+ return (
+
+ {name}
+
+ );
+}
+
+function Filter({
+ column: { getFilterValue, setFilterValue, id },
+}: {
+ column: Column;
+}) {
+ const value = getFilterValue();
+
+ const valueAsArray = getValueAsArrayOfStrings(value);
+
+ return (
+
+ );
+}
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/index.ts b/app/react/docker/stacks/ListView/StacksDatatable/index.ts
new file mode 100644
index 000000000..9b1d8711a
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/index.ts
@@ -0,0 +1 @@
+export { StacksDatatable } from './StacksDatatable';
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/store.ts b/app/react/docker/stacks/ListView/StacksDatatable/store.ts
new file mode 100644
index 000000000..25f2743b3
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/store.ts
@@ -0,0 +1,27 @@
+import {
+ BasicTableSettings,
+ RefreshableTableSettings,
+ SettableColumnsTableSettings,
+ createPersistedStore,
+ hiddenColumnsSettings,
+ refreshableSettings,
+} from '@@/datatables/types';
+
+export interface TableSettings
+ extends BasicTableSettings,
+ SettableColumnsTableSettings,
+ RefreshableTableSettings {
+ showOrphanedStacks: boolean;
+ setShowOrphanedStacks(value: boolean): void;
+}
+
+export function createStore(storageKey: string) {
+ return createPersistedStore(storageKey, 'name', (set) => ({
+ ...hiddenColumnsSettings(set),
+ ...refreshableSettings(set),
+ showOrphanedStacks: false,
+ setShowOrphanedStacks(showOrphanedStacks) {
+ set((s) => ({ ...s, showOrphanedStacks }));
+ },
+ }));
+}
diff --git a/app/react/docker/stacks/ListView/StacksDatatable/types.ts b/app/react/docker/stacks/ListView/StacksDatatable/types.ts
new file mode 100644
index 000000000..afc8e0959
--- /dev/null
+++ b/app/react/docker/stacks/ListView/StacksDatatable/types.ts
@@ -0,0 +1,4 @@
+import { StackViewModel } from '../../view-models/stack';
+import { ExternalStackViewModel } from '../../view-models/external-stack';
+
+export type DecoratedStack = StackViewModel | ExternalStackViewModel;
diff --git a/app/react/docker/stacks/view-models/external-stack.ts b/app/react/docker/stacks/view-models/external-stack.ts
new file mode 100644
index 000000000..120dbe8ed
--- /dev/null
+++ b/app/react/docker/stacks/view-models/external-stack.ts
@@ -0,0 +1,35 @@
+import _ from 'lodash';
+
+import { StackType } from '@/react/common/stacks/types';
+import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
+
+import { IResource } from '../../components/datatable/createOwnershipColumn';
+
+export class ExternalStackViewModel implements IResource {
+ Id: string;
+
+ Name: string;
+
+ ResourceControl?: ResourceControlViewModel;
+
+ Type: StackType;
+
+ CreationDate: number;
+
+ CreatedBy?: string;
+
+ UpdateDate?: number;
+
+ UpdatedBy?: string;
+
+ External: boolean;
+
+ constructor(name: string, type: StackType, creationDate: number) {
+ this.Id = `external-stack_${_.uniqueId()}`;
+ this.Name = name;
+ this.Type = type;
+ this.CreationDate = creationDate;
+
+ this.External = true;
+ }
+}
diff --git a/app/react/docker/stacks/view-models/stack.ts b/app/react/docker/stacks/view-models/stack.ts
new file mode 100644
index 000000000..1e90dd835
--- /dev/null
+++ b/app/react/docker/stacks/view-models/stack.ts
@@ -0,0 +1,99 @@
+import { Stack, StackStatus, StackType } from '@/react/common/stacks/types';
+import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import {
+ AutoUpdateResponse,
+ RepoConfigResponse,
+} from '@/react/portainer/gitops/types';
+
+import { IResource } from '../../components/datatable/createOwnershipColumn';
+
+export class StackViewModel implements IResource {
+ Id: number;
+
+ Type: StackType;
+
+ Name: string;
+
+ EndpointId: EnvironmentId;
+
+ SwarmId: string;
+
+ Env: { name: string; value: string }[];
+
+ Option: { Prune: boolean; Force: boolean } | undefined;
+
+ IsComposeFormat: boolean;
+
+ ResourceControl?: ResourceControlViewModel;
+
+ Status: StackStatus;
+
+ CreationDate: number;
+
+ CreatedBy: string;
+
+ UpdateDate: number;
+
+ UpdatedBy: string;
+
+ Regular: boolean;
+
+ External: boolean;
+
+ Orphaned: boolean;
+
+ OrphanedRunning: boolean;
+
+ GitConfig: RepoConfigResponse | undefined;
+
+ FromAppTemplate: boolean;
+
+ AdditionalFiles: string[] | undefined;
+
+ AutoUpdate: AutoUpdateResponse | undefined;
+
+ Webhook: string | undefined;
+
+ StackFileVersion: string;
+
+ PreviousDeploymentInfo: unknown;
+
+ constructor(stack: Stack, orphaned = false) {
+ this.Id = stack.Id;
+ this.Type = stack.Type;
+ this.Name = stack.Name;
+ this.EndpointId = stack.EndpointID;
+ this.SwarmId = stack.SwarmID;
+ this.Env = stack.Env ? stack.Env : [];
+ this.Option = stack.Option;
+ this.IsComposeFormat = stack.IsComposeFormat;
+
+ if (stack.ResourceControl && stack.ResourceControl.Id !== 0) {
+ this.ResourceControl = new ResourceControlViewModel(
+ stack.ResourceControl
+ );
+ }
+
+ this.Status = stack.Status;
+
+ this.CreationDate = stack.CreationDate;
+ this.CreatedBy = stack.CreatedBy;
+
+ this.UpdateDate = stack.UpdateDate;
+ this.UpdatedBy = stack.UpdatedBy;
+
+ this.GitConfig = stack.GitConfig;
+ this.FromAppTemplate = stack.FromAppTemplate;
+ this.AdditionalFiles = stack.AdditionalFiles;
+ this.AutoUpdate = stack.AutoUpdate;
+ this.Webhook = stack.Webhook;
+ this.StackFileVersion = stack.StackFileVersion;
+ this.PreviousDeploymentInfo = stack.PreviousDeploymentInfo;
+
+ this.Regular = !orphaned;
+ this.External = false;
+ this.Orphaned = orphaned;
+ this.OrphanedRunning = false;
+ }
+}
diff --git a/app/react/docker/stacks/view-models/utils.ts b/app/react/docker/stacks/view-models/utils.ts
new file mode 100644
index 000000000..c028b330a
--- /dev/null
+++ b/app/react/docker/stacks/view-models/utils.ts
@@ -0,0 +1,20 @@
+import { ExternalStackViewModel } from './external-stack';
+import { StackViewModel } from './stack';
+
+export function isExternalStack(
+ stack: StackViewModel | ExternalStackViewModel
+): stack is ExternalStackViewModel {
+ return 'External' in stack && stack.External;
+}
+
+export function isRegularStack(
+ stack: StackViewModel | ExternalStackViewModel
+): stack is StackViewModel & { Regular: true } {
+ return 'Regular' in stack && stack.Regular;
+}
+
+export function isOrphanedStack(
+ stack: StackViewModel | ExternalStackViewModel
+): stack is StackViewModel & { Orphaned: true } {
+ return 'Orphaned' in stack && stack.Orphaned;
+}