From c8a1f0fa77df40e2fe4cfad134b4dc949871ff81 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 7 Sep 2023 15:59:59 +0100 Subject: [PATCH] refactor(docker/stacks): migrate table to react [EE-4705] (#9956) --- app/assets/ico/icon_updates-unknown.svg | 3 + app/docker/models/secret.ts | 2 +- app/docker/react/components/index.ts | 11 +- .../stacks-datatable/stacksDatatable.html | 262 ------------------ .../stacks-datatable/stacksDatatable.js | 15 - .../stacksDatatableController.js | 108 -------- app/portainer/helpers/stackHelper.js | 2 +- app/portainer/models/stack.js | 63 ----- app/portainer/services/api/stackService.js | 14 +- app/portainer/views/stacks/stacks.html | 21 +- app/react/common/stacks/types.ts | 41 +++ .../components/datatables/TableHeaderCell.tsx | 10 +- .../extend-options/withGlobalFilter.ts | 9 +- .../components/datatables/useRepeater.ts | 2 +- .../components/ImageStatus/ImageStatus.tsx | 85 ++++++ .../docker/components/ImageStatus/helpers.ts | 20 ++ .../docker/components/ImageStatus/index.js | 1 + .../TableColumnHeaderImageUpToDate.tsx | 65 +++++ .../createOwnershipColumn.tsx | 8 +- .../ListView/ConfigsDatatable/columns.tsx | 2 +- .../ContainersDatatable/columns/index.tsx | 2 +- app/react/docker/images/.keep | 0 app/react/docker/images/image.service.ts | 47 ++++ .../secrets/ListView/SecretsDatatable.tsx | 2 +- app/react/docker/services/.keep | 0 app/react/docker/services/types.ts | 16 ++ .../StacksDatatable/StacksDatatable.tsx | 113 ++++++++ .../ListView/StacksDatatable/TableActions.tsx | 45 +++ .../StacksDatatable/TableSettingsMenus.tsx | 61 ++++ .../columns/StackImageStatus.tsx | 56 ++++ .../StacksDatatable/columns/control.tsx | 63 +++++ .../columns/deployed-version.tsx | 41 +++ .../columns/getStackImagesStatus.ts | 16 ++ .../StacksDatatable/columns/helper.ts | 5 + .../columns/image-notification.tsx | 34 +++ .../ListView/StacksDatatable/columns/index.ts | 58 ++++ .../ListView/StacksDatatable/columns/name.tsx | 130 +++++++++ .../stacks/ListView/StacksDatatable/index.ts | 1 + .../stacks/ListView/StacksDatatable/store.ts | 27 ++ .../stacks/ListView/StacksDatatable/types.ts | 4 + .../stacks/view-models/external-stack.ts | 35 +++ app/react/docker/stacks/view-models/stack.ts | 99 +++++++ app/react/docker/stacks/view-models/utils.ts | 20 ++ 43 files changed, 1127 insertions(+), 492 deletions(-) create mode 100644 app/assets/ico/icon_updates-unknown.svg delete mode 100644 app/portainer/components/datatables/stacks-datatable/stacksDatatable.html delete mode 100644 app/portainer/components/datatables/stacks-datatable/stacksDatatable.js delete mode 100644 app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js delete mode 100644 app/portainer/models/stack.js create mode 100644 app/react/docker/components/ImageStatus/ImageStatus.tsx create mode 100644 app/react/docker/components/ImageStatus/helpers.ts create mode 100644 app/react/docker/components/ImageStatus/index.js create mode 100644 app/react/docker/components/datatable/TableColumnHeaderImageUpToDate.tsx rename app/react/docker/components/{datatable-helpers => datatable}/createOwnershipColumn.tsx (85%) delete mode 100644 app/react/docker/images/.keep create mode 100644 app/react/docker/images/image.service.ts delete mode 100644 app/react/docker/services/.keep create mode 100644 app/react/docker/services/types.ts create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/StacksDatatable.tsx create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/TableActions.tsx create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/TableSettingsMenus.tsx create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/columns/StackImageStatus.tsx create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/columns/control.tsx create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/columns/deployed-version.tsx create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/columns/getStackImagesStatus.ts create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/columns/helper.ts create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/columns/image-notification.tsx create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/columns/index.ts create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/columns/name.tsx create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/index.ts create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/store.ts create mode 100644 app/react/docker/stacks/ListView/StacksDatatable/types.ts create mode 100644 app/react/docker/stacks/view-models/external-stack.ts create mode 100644 app/react/docker/stacks/view-models/stack.ts create mode 100644 app/react/docker/stacks/view-models/utils.ts 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 @@ -
- - -
-
-
- -
- {{ $ctrl.titleText }} -
- -
- - -
-
- - - - - - - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - - -
- - Filter - - - -
- -
-
- - - - - - - -
- - - - - {{ item.Name }} - Inactive - {{ item.Type === 1 ? 'Swarm' : 'Compose' }} - - Orphaned - - - - Limited - - - Total - - {{ item.CreationDate | getisodatefromtimestamp }} {{ item.CreatedBy ? 'by ' + item.CreatedBy : '' }} - - - - {{ item.UpdateDate | getisodatefromtimestamp }} {{ item.UpdatedBy ? 'by ' + item.UpdatedBy : '' }} - - - - - - {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }} - -
Loading...
No stack available.
-
- -
-
-
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; +}