mirror of https://github.com/portainer/portainer
				
				
				
			refactor(docker/stacks): migrate table to react [EE-4705] (#9956)
							parent
							
								
									c3d266931f
								
							
						
					
					
						commit
						c8a1f0fa77
					
				| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
<svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
 | 
			
		||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.89453 7C2.89453 6.44772 3.34225 6 3.89453 6L11.1055 6C11.6578 6 12.1055 6.44771 12.1055 7C12.1055 7.55228 11.6578 8 11.1055 8L3.89453 8C3.34225 8 2.89453 7.55228 2.89453 7Z" fill="#98A2B3"/>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 346 B  | 
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,262 +0,0 @@
 | 
			
		|||
<div class="datatable">
 | 
			
		||||
  <rd-widget>
 | 
			
		||||
    <rd-widget-body classes="no-padding" ng-if="$ctrl.createEnabled">
 | 
			
		||||
      <div class="toolBar vertical-center flex-wrap !gap-x-5 !gap-y-1">
 | 
			
		||||
        <div class="toolBarTitle vertical-center">
 | 
			
		||||
          <div class="widget-icon space-right">
 | 
			
		||||
            <pr-icon icon="'layers'"></pr-icon>
 | 
			
		||||
          </div>
 | 
			
		||||
          {{ $ctrl.titleText }}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="searchBar vertical-center !mr-0">
 | 
			
		||||
          <pr-icon icon="'search'" class-name="'searchIcon'"></pr-icon>
 | 
			
		||||
          <input
 | 
			
		||||
            type="text"
 | 
			
		||||
            class="searchInput"
 | 
			
		||||
            ng-model="$ctrl.state.textFilter"
 | 
			
		||||
            ng-change="$ctrl.onTextFilterChange()"
 | 
			
		||||
            placeholder="Search for a stack..."
 | 
			
		||||
            auto-focus
 | 
			
		||||
            ng-model-options="{ debounce: 300 }"
 | 
			
		||||
            data-cy="stack-searchInput"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="actionBar !gap-3" authorization="PortainerStackCreate, PortainerStackDelete">
 | 
			
		||||
          <button
 | 
			
		||||
            type="button"
 | 
			
		||||
            class="btn btn-sm btn-dangerlight vertical-center !ml-0 h-fit"
 | 
			
		||||
            authorization="PortainerStackDelete"
 | 
			
		||||
            ng-disabled="!$ctrl.createEnabled || $ctrl.state.selectedItemCount === 0"
 | 
			
		||||
            ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
 | 
			
		||||
            data-cy="stack-removeStackButton"
 | 
			
		||||
          >
 | 
			
		||||
            <pr-icon icon="'trash-2'"></pr-icon>Remove
 | 
			
		||||
          </button>
 | 
			
		||||
          <button
 | 
			
		||||
            ng-disabled="!$ctrl.createEnabled"
 | 
			
		||||
            type="button"
 | 
			
		||||
            class="btn btn-sm btn-primary vertical-center !ml-0 h-fit"
 | 
			
		||||
            ui-sref="docker.stacks.newstack"
 | 
			
		||||
            authorization="PortainerStackCreate"
 | 
			
		||||
            data-cy="stack-addStackButton"
 | 
			
		||||
          >
 | 
			
		||||
            <pr-icon icon="'plus'"></pr-icon>Add stack
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="settings">
 | 
			
		||||
          <datatable-columns-visibility columns="$ctrl.columnVisibility.columns" on-change="($ctrl.onColumnVisibilityChange)"></datatable-columns-visibility>
 | 
			
		||||
          <span class="setting ml-2" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
 | 
			
		||||
            <span uib-dropdown-toggle aria-label="Settings">
 | 
			
		||||
              <pr-icon icon="'more-vertical'"></pr-icon>
 | 
			
		||||
            </span>
 | 
			
		||||
            <div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
 | 
			
		||||
              <div class="tableMenu">
 | 
			
		||||
                <div class="menuHeader"> Table settings </div>
 | 
			
		||||
                <div class="menuContent">
 | 
			
		||||
                  <div>
 | 
			
		||||
                    <div class="md-checkbox" ng-if="$ctrl.isAdmin">
 | 
			
		||||
                      <input id="setting_all_orphaned_stacks" type="checkbox" ng-model="$ctrl.settings.allOrphanedStacks" ng-change="$ctrl.onSettingsAllOrphanedStacksChange()" />
 | 
			
		||||
                      <label for="setting_all_orphaned_stacks">Show all orphaned stacks</label>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="md-checkbox">
 | 
			
		||||
                      <input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()" />
 | 
			
		||||
                      <label for="setting_auto_refresh">Auto refresh</label>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div ng-if="$ctrl.settings.repeater.autoRefresh">
 | 
			
		||||
                      <label for="settings_refresh_rate"> Refresh rate </label>
 | 
			
		||||
                      <select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
 | 
			
		||||
                        <option value="10">10s</option>
 | 
			
		||||
                        <option value="30">30s</option>
 | 
			
		||||
                        <option value="60">1min</option>
 | 
			
		||||
                        <option value="120">2min</option>
 | 
			
		||||
                        <option value="300">5min</option>
 | 
			
		||||
                      </select>
 | 
			
		||||
                      <span>
 | 
			
		||||
                        <pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
 | 
			
		||||
                      </span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div>
 | 
			
		||||
                  <a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="table-responsive">
 | 
			
		||||
        <table class="table-hover nowrap-cells table" data-cy="stack-stackTable">
 | 
			
		||||
          <thead>
 | 
			
		||||
            <tr>
 | 
			
		||||
              <th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open">
 | 
			
		||||
                <div class="flex-no-wrap flex flex-row gap-1">
 | 
			
		||||
                  <span class="md-checkbox" authorization="PortainerStackCreate, PortainerStackDelete">
 | 
			
		||||
                    <input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
 | 
			
		||||
                    <label for="select_all"></label>
 | 
			
		||||
                  </span>
 | 
			
		||||
                  <table-column-header
 | 
			
		||||
                    col-title="'Name'"
 | 
			
		||||
                    can-sort="true"
 | 
			
		||||
                    is-sorted="$ctrl.state.orderBy === 'Name'"
 | 
			
		||||
                    is-sorted-desc="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"
 | 
			
		||||
                    ng-click="$ctrl.changeOrderBy('Name')"
 | 
			
		||||
                  ></table-column-header>
 | 
			
		||||
                  <div>
 | 
			
		||||
                    <span uib-dropdown-toggle ng-class="['table-filter vertical-center !ml-1', { 'filter-active': $ctrl.filters.state.enabled }]">
 | 
			
		||||
                      Filter
 | 
			
		||||
                      <pr-icon ng-if="$ctrl.filters.state.enabled" icon="'check'" size="'sm'"></pr-icon>
 | 
			
		||||
                      <pr-icon ng-if="!$ctrl.filters.state.enabled" icon="'filter'" size="'sm'"></pr-icon>
 | 
			
		||||
                    </span>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  <div class="dropdown-menu" uib-dropdown-menu>
 | 
			
		||||
                    <div class="tableMenu">
 | 
			
		||||
                      <div class="menuHeader"> Filter by activity </div>
 | 
			
		||||
                      <div class="menuContent">
 | 
			
		||||
                        <div class="md-checkbox">
 | 
			
		||||
                          <input id="filter_usage_activeStacks" type="checkbox" ng-model="$ctrl.filters.state.showActiveStacks" ng-change="$ctrl.onFilterChange()" />
 | 
			
		||||
                          <label for="filter_usage_activeStacks">Active stacks</label>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <div class="md-checkbox">
 | 
			
		||||
                          <input id="filter_usage_unactiveStacks" type="checkbox" ng-model="$ctrl.filters.state.showUnactiveStacks" ng-change="$ctrl.onFilterChange()" />
 | 
			
		||||
                          <label for="filter_usage_unactiveStacks">Inactive stacks</label>
 | 
			
		||||
                        </div>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      <div>
 | 
			
		||||
                        <a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.filters.state.open = false;">Close</a>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </th>
 | 
			
		||||
              <th>
 | 
			
		||||
                <table-column-header
 | 
			
		||||
                  col-title="'Type'"
 | 
			
		||||
                  can-sort="true"
 | 
			
		||||
                  is-sorted="$ctrl.state.orderBy === 'Type'"
 | 
			
		||||
                  is-sorted-desc="$ctrl.state.orderBy === 'Type' && $ctrl.state.reverseOrder"
 | 
			
		||||
                  ng-click="$ctrl.changeOrderBy('Type')"
 | 
			
		||||
                ></table-column-header>
 | 
			
		||||
              </th>
 | 
			
		||||
              <th><table-column-header col-title="'Control'" can-sort="false"></table-column-header></th>
 | 
			
		||||
              <th>
 | 
			
		||||
                <table-column-header
 | 
			
		||||
                  col-title="'Created'"
 | 
			
		||||
                  can-sort="true"
 | 
			
		||||
                  is-sorted="$ctrl.state.orderBy === 'ResourceControl.CreationDate'"
 | 
			
		||||
                  is-sorted-desc="$ctrl.state.orderBy === 'ResourceControl.CreationDate' && $ctrl.state.reverseOrder"
 | 
			
		||||
                  ng-click="$ctrl.changeOrderBy('ResourceControl.CreationDate')"
 | 
			
		||||
                ></table-column-header>
 | 
			
		||||
              </th>
 | 
			
		||||
              <th ng-if="$ctrl.columnVisibility.columns.updated.display">
 | 
			
		||||
                <table-column-header
 | 
			
		||||
                  col-title="'Updated'"
 | 
			
		||||
                  can-sort="true"
 | 
			
		||||
                  is-sorted="$ctrl.state.orderBy === 'ResourceControl.UpdateDate'"
 | 
			
		||||
                  is-sorted-desc="$ctrl.state.orderBy === 'ResourceControl.UpdateDate' && $ctrl.state.reverseOrder"
 | 
			
		||||
                  ng-click="$ctrl.changeOrderBy('ResourceControl.UpdateDate')"
 | 
			
		||||
                ></table-column-header>
 | 
			
		||||
              </th>
 | 
			
		||||
              <th>
 | 
			
		||||
                <table-column-header
 | 
			
		||||
                  col-title="'Ownership'"
 | 
			
		||||
                  can-sort="true"
 | 
			
		||||
                  is-sorted="$ctrl.state.orderBy === 'ResourceControl.Ownership'"
 | 
			
		||||
                  is-sorted-desc="$ctrl.state.orderBy === 'ResourceControl.Ownership' && $ctrl.state.reverseOrder"
 | 
			
		||||
                  ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')"
 | 
			
		||||
                ></table-column-header>
 | 
			
		||||
              </th>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </thead>
 | 
			
		||||
          <tbody>
 | 
			
		||||
            <tr
 | 
			
		||||
              dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
 | 
			
		||||
              ng-class="{ active: item.Checked }"
 | 
			
		||||
            >
 | 
			
		||||
              <td>
 | 
			
		||||
                <span class="md-checkbox" authorization="PortainerStackCreate, PortainerStackDelete">
 | 
			
		||||
                  <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" ng-disabled="!$ctrl.allowSelection(item)" />
 | 
			
		||||
                  <label for="select_{{ $index }}"></label>
 | 
			
		||||
                </span>
 | 
			
		||||
                <a
 | 
			
		||||
                  ng-if="$ctrl.createEnabled"
 | 
			
		||||
                  ui-sref="docker.stacks.stack({ name: item.Name, id: item.Id, type: item.Type, regular: item.Regular, external: item.External, orphaned: item.Orphaned, orphanedRunning: item.OrphanedRunning })"
 | 
			
		||||
                  >{{ item.Name }}</a
 | 
			
		||||
                >
 | 
			
		||||
                <span ng-if="item.Regular && item.Status == 2" class="label label-warning image-tag ml-2">Inactive</span>
 | 
			
		||||
              </td>
 | 
			
		||||
              <td>{{ item.Type === 1 ? 'Swarm' : 'Compose' }}</td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <span
 | 
			
		||||
                  ng-if="item.Orphaned"
 | 
			
		||||
                  class="interactive vertical-center"
 | 
			
		||||
                  tooltip-append-to-body="true"
 | 
			
		||||
                  tooltip-placement="bottom"
 | 
			
		||||
                  tooltip-class="portainer-tooltip"
 | 
			
		||||
                  uib-tooltip="This stack was created inside an environment that is no longer registered inside Portainer."
 | 
			
		||||
                >
 | 
			
		||||
                  Orphaned
 | 
			
		||||
                  <pr-icon icon="'alert-circle'" class-name="'ml-0.5'" mode="'warning'"></pr-icon>
 | 
			
		||||
                </span>
 | 
			
		||||
                <span
 | 
			
		||||
                  ng-if="item.External"
 | 
			
		||||
                  class="interactive vertical-center"
 | 
			
		||||
                  tooltip-append-to-body="true"
 | 
			
		||||
                  tooltip-placement="bottom"
 | 
			
		||||
                  tooltip-class="portainer-tooltip"
 | 
			
		||||
                  uib-tooltip="This stack was created outside of Portainer. Control over this stack is limited."
 | 
			
		||||
                >
 | 
			
		||||
                  Limited
 | 
			
		||||
                  <pr-icon icon="'alert-circle'" class-name="'ml-0.5'" mode="'warning'"></pr-icon>
 | 
			
		||||
                </span>
 | 
			
		||||
                <span ng-if="item.Regular">Total</span>
 | 
			
		||||
              </td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <span ng-if="item.CreationDate">{{ item.CreationDate | getisodatefromtimestamp }} {{ item.CreatedBy ? 'by ' + item.CreatedBy : '' }}</span>
 | 
			
		||||
                <span ng-if="!item.CreationDate"> - </span>
 | 
			
		||||
              </td>
 | 
			
		||||
              <td ng-if="$ctrl.columnVisibility.columns.updated.display">
 | 
			
		||||
                <span ng-if="item.UpdateDate">{{ item.UpdateDate | getisodatefromtimestamp }} {{ item.UpdatedBy ? 'by ' + item.UpdatedBy : '' }}</span>
 | 
			
		||||
                <span ng-if="!item.UpdateDate"> - </span>
 | 
			
		||||
              </td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <span class="vertical-center">
 | 
			
		||||
                  <pr-icon ng-attr-icon="item.ResourceControl.Ownership | ownershipicon" class-name="'icon ml-0.5'"></pr-icon>
 | 
			
		||||
                  {{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }}
 | 
			
		||||
                </span>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr ng-if="!$ctrl.dataset" data-cy="stacks-loadingTableRow">
 | 
			
		||||
              <td colspan="6" class="text-muted text-center">Loading...</td>
 | 
			
		||||
            </tr>
 | 
			
		||||
            <tr ng-if="$ctrl.state.filteredDataSet.length === 0" data-cy="stacks-noStackTableRow">
 | 
			
		||||
              <td colspan="6" class="text-muted text-center">No stack available.</td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </tbody>
 | 
			
		||||
        </table>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="footer pl-5" ng-if="$ctrl.dataset">
 | 
			
		||||
        <div class="infoBar !ml-0" ng-if="$ctrl.state.selectedItemCount !== 0">
 | 
			
		||||
          {{ $ctrl.state.selectedItemCount }}
 | 
			
		||||
          item(s) selected
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="paginationControls">
 | 
			
		||||
          <form class="form-inline">
 | 
			
		||||
            <span class="limitSelector">
 | 
			
		||||
              <span class="mr-1"> Items per page </span>
 | 
			
		||||
              <select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
 | 
			
		||||
                <option value="0">All</option>
 | 
			
		||||
                <option value="10">10</option>
 | 
			
		||||
                <option value="25">25</option>
 | 
			
		||||
                <option value="50">50</option>
 | 
			
		||||
                <option value="100">100</option>
 | 
			
		||||
              </select>
 | 
			
		||||
            </span>
 | 
			
		||||
            <dir-pagination-controls max-size="5"></dir-pagination-controls>
 | 
			
		||||
          </form>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </rd-widget-body>
 | 
			
		||||
  </rd-widget>
 | 
			
		||||
</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -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: '<',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
| 
						 | 
				
			
			@ -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() {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,16 +1,9 @@
 | 
			
		|||
<page-header title="'Stacks list'" breadcrumbs="['Stacks']" reload="true"> </page-header>
 | 
			
		||||
 | 
			
		||||
<div class="row">
 | 
			
		||||
  <div class="col-sm-12">
 | 
			
		||||
    <stacks-datatable
 | 
			
		||||
      title-text="Stacks"
 | 
			
		||||
      title-icon="list"
 | 
			
		||||
      dataset="stacks"
 | 
			
		||||
      table-key="stacks"
 | 
			
		||||
      order-by="Name"
 | 
			
		||||
      remove-action="removeAction"
 | 
			
		||||
      refresh-callback="getStacks"
 | 
			
		||||
      create-enabled="createEnabled"
 | 
			
		||||
    ></stacks-datatable>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
<docker-stacks-datatable
 | 
			
		||||
  ng-if="stacks"
 | 
			
		||||
  dataset="stacks"
 | 
			
		||||
  is-image-notification-enabled="enableImageNotification"
 | 
			
		||||
  on-remove="(removeAction)"
 | 
			
		||||
  on-refresh="(getStacks)"
 | 
			
		||||
></docker-stacks-datatable>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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'
 | 
			
		||||
      )}
 | 
			
		||||
    >
 | 
			
		||||
      <div className="flex h-full w-full flex-row items-center justify-start">
 | 
			
		||||
| 
						 | 
				
			
			@ -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<TableColumnHeaderAngularProps>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex h-full flex-row flex-nowrap">
 | 
			
		||||
      <SortWrapper
 | 
			
		||||
| 
						 | 
				
			
			@ -103,6 +102,7 @@ export function TableColumnHeaderAngular({
 | 
			
		|||
        isSortedDesc={isSortedDesc}
 | 
			
		||||
      >
 | 
			
		||||
        {colTitle}
 | 
			
		||||
        {children}
 | 
			
		||||
      </SortWrapper>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,9 +3,12 @@ import { TableOptions } from '@tanstack/react-table';
 | 
			
		|||
import { defaultGlobalFilterFn } from '../Datatable';
 | 
			
		||||
import { DefaultType } from '../types';
 | 
			
		||||
 | 
			
		||||
export function withGlobalFilter<D extends DefaultType>(
 | 
			
		||||
  filterFn: typeof defaultGlobalFilterFn
 | 
			
		||||
) {
 | 
			
		||||
export function withGlobalFilter<
 | 
			
		||||
  D extends DefaultType,
 | 
			
		||||
  TFilter extends {
 | 
			
		||||
    search: string;
 | 
			
		||||
  },
 | 
			
		||||
>(filterFn: typeof defaultGlobalFilterFn<D, TFilter>) {
 | 
			
		||||
  return function extendOptions(options: TableOptions<D>) {
 | 
			
		||||
    return {
 | 
			
		||||
      ...options,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@ import { useEffect, useCallback, useState } from 'react';
 | 
			
		|||
 | 
			
		||||
export function useRepeater(
 | 
			
		||||
  refreshRate: number,
 | 
			
		||||
  onRefresh?: () => Promise<void>
 | 
			
		||||
  onRefresh?: () => Promise<void> | void
 | 
			
		||||
) {
 | 
			
		||||
  const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 (
 | 
			
		||||
      <Icon
 | 
			
		||||
        icon={Loader}
 | 
			
		||||
        size="sm"
 | 
			
		||||
        className="!mr-1 animate-spin-slow align-middle"
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Icon icon={statusIcon(data)} size="sm" className="!mr-1 align-middle" />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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,
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export { ImageStatus } from './ImageStatus';
 | 
			
		||||
| 
						 | 
				
			
			@ -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 (
 | 
			
		||||
    <TableColumnHeaderAngular
 | 
			
		||||
      canSort={canSort}
 | 
			
		||||
      isSorted={isSorted}
 | 
			
		||||
      colTitle={colTitle}
 | 
			
		||||
      isSortedDesc={isSortedDesc}
 | 
			
		||||
    >
 | 
			
		||||
      <ImageUpToDateTooltip />
 | 
			
		||||
    </TableColumnHeaderAngular>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ImageUpToDateTooltip() {
 | 
			
		||||
  const environmentId = useEnvironmentId();
 | 
			
		||||
 | 
			
		||||
  const enableImageNotificationQuery = useEnvironment(
 | 
			
		||||
    environmentId,
 | 
			
		||||
    (environment) => environment?.EnableImageNotification
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (!enableImageNotificationQuery.data) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Tooltip
 | 
			
		||||
      position="top"
 | 
			
		||||
      message={
 | 
			
		||||
        <div className="flex flex-col gap-y-2 p-2">
 | 
			
		||||
          <div className="flex items-center gap-2">
 | 
			
		||||
            <Icon icon={UpToDate} />
 | 
			
		||||
            Images are up to date
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="flex items-center gap-2">
 | 
			
		||||
            <Icon icon={UpdatesAvailable} />
 | 
			
		||||
            Updates are available
 | 
			
		||||
          </div>
 | 
			
		||||
          <div className="flex items-center gap-2">
 | 
			
		||||
            <Icon icon={UpdatesUnknown} />
 | 
			
		||||
            Updates availability unknown
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      }
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -11,16 +11,16 @@ export interface IResource {
 | 
			
		|||
  };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function createOwnershipColumn<D extends IResource>(): ColumnDef<
 | 
			
		||||
  D,
 | 
			
		||||
  ResourceControlOwnership
 | 
			
		||||
> {
 | 
			
		||||
export function createOwnershipColumn<D extends IResource>(
 | 
			
		||||
  enableHiding = true
 | 
			
		||||
): ColumnDef<D, ResourceControlOwnership> {
 | 
			
		||||
  return {
 | 
			
		||||
    accessorFn: (row) =>
 | 
			
		||||
      row.ResourceControl?.Ownership || ResourceControlOwnership.ADMINISTRATORS,
 | 
			
		||||
    header: 'Ownership',
 | 
			
		||||
    id: 'ownership',
 | 
			
		||||
    cell: OwnershipCell,
 | 
			
		||||
    enableHiding,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  function OwnershipCell({
 | 
			
		||||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<ImageStatus>(
 | 
			
		||||
      `/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<ImageStatus>(
 | 
			
		||||
      `/docker/${environmentId}/services/${serviceID}/image_status`
 | 
			
		||||
    );
 | 
			
		||||
    return data;
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    return {
 | 
			
		||||
      Status: 'unknown',
 | 
			
		||||
      Message: `Unable to retrieve image status for service: ${serviceID}`,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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<SecretViewModel>();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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<DecoratedStack>) => void;
 | 
			
		||||
  onReload: () => void;
 | 
			
		||||
  isImageNotificationEnabled: boolean;
 | 
			
		||||
  dataset: Array<DecoratedStack>;
 | 
			
		||||
}) {
 | 
			
		||||
  const tableState = useTableState(settingsStore, tableKey);
 | 
			
		||||
  useRepeater(tableState.autoRefreshRate, onReload);
 | 
			
		||||
  const { isAdmin } = useCurrentUser();
 | 
			
		||||
  const canManageStacks = useAuthorizations([
 | 
			
		||||
    'PortainerStackCreate',
 | 
			
		||||
    'PortainerStackDelete',
 | 
			
		||||
  ]);
 | 
			
		||||
  const columns = useColumns(isImageNotificationEnabled);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Datatable<DecoratedStack>
 | 
			
		||||
      settingsManager={tableState}
 | 
			
		||||
      title="Stacks"
 | 
			
		||||
      titleIcon={Layers}
 | 
			
		||||
      renderTableActions={(selectedRows) => (
 | 
			
		||||
        <TableActions selectedItems={selectedRows} onRemove={onRemove} />
 | 
			
		||||
      )}
 | 
			
		||||
      renderTableSettings={(tableInstance) => (
 | 
			
		||||
        <TableSettingsMenus
 | 
			
		||||
          tableInstance={tableInstance}
 | 
			
		||||
          tableState={tableState}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
      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<DecoratedStack>,
 | 
			
		||||
  columnId: string,
 | 
			
		||||
  filterValue: null | { showOrphanedStacks: boolean; search: string }
 | 
			
		||||
) {
 | 
			
		||||
  return (
 | 
			
		||||
    orphanedFilter(row, filterValue) &&
 | 
			
		||||
    defaultGlobalFilterFn(row, columnId, filterValue)
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function orphanedFilter(
 | 
			
		||||
  row: Row<DecoratedStack>,
 | 
			
		||||
  filterValue: null | { showOrphanedStacks: boolean; search: string }
 | 
			
		||||
) {
 | 
			
		||||
  if (filterValue?.showOrphanedStacks) {
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return !isOrphanedStack(row.original);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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<DecoratedStack>;
 | 
			
		||||
  onRemove: (items: Array<DecoratedStack>) => void;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="flex items-center gap-2">
 | 
			
		||||
      <Authorized authorizations="PortainerStackDelete">
 | 
			
		||||
        <Button
 | 
			
		||||
          color="dangerlight"
 | 
			
		||||
          disabled={selectedItems.length === 0}
 | 
			
		||||
          onClick={() => onRemove(selectedItems)}
 | 
			
		||||
          icon={Trash2}
 | 
			
		||||
          className="!m-0"
 | 
			
		||||
          data-cy="stack-removeStackButton"
 | 
			
		||||
        >
 | 
			
		||||
          Remove
 | 
			
		||||
        </Button>
 | 
			
		||||
      </Authorized>
 | 
			
		||||
 | 
			
		||||
      <Authorized authorizations="PortainerStackCreate">
 | 
			
		||||
        <Button
 | 
			
		||||
          as={Link}
 | 
			
		||||
          props={{ to: '.newstack' }}
 | 
			
		||||
          icon={Plus}
 | 
			
		||||
          className="!m-0"
 | 
			
		||||
          data-cy="stack-addStackButton"
 | 
			
		||||
        >
 | 
			
		||||
          Add stack
 | 
			
		||||
        </Button>
 | 
			
		||||
      </Authorized>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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<DecoratedStack>;
 | 
			
		||||
  tableState: TableSettings;
 | 
			
		||||
}) {
 | 
			
		||||
  const columnsToHide = tableInstance
 | 
			
		||||
    .getAllColumns()
 | 
			
		||||
    .filter((col) => col.getCanHide());
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <ColumnVisibilityMenu<DecoratedStack>
 | 
			
		||||
        columns={columnsToHide}
 | 
			
		||||
        onChange={(hiddenColumns) => {
 | 
			
		||||
          tableState.setHiddenColumns(hiddenColumns);
 | 
			
		||||
          tableInstance.setColumnVisibility(
 | 
			
		||||
            Object.fromEntries(hiddenColumns.map((col) => [col, false]))
 | 
			
		||||
          );
 | 
			
		||||
        }}
 | 
			
		||||
        value={tableState.hiddenColumns}
 | 
			
		||||
      />
 | 
			
		||||
      <TableSettingsMenu>
 | 
			
		||||
        {isBE && (
 | 
			
		||||
          <Authorized authorizations="EndpointResourcesAccess">
 | 
			
		||||
            <Checkbox
 | 
			
		||||
              id="setting_all_orphaned_stacks"
 | 
			
		||||
              label="Show all orphaned stacks"
 | 
			
		||||
              checked={tableState.showOrphanedStacks}
 | 
			
		||||
              onChange={(e) => {
 | 
			
		||||
                tableState.setShowOrphanedStacks(e.target.checked);
 | 
			
		||||
                tableInstance.setGlobalFilter((filter: object) => ({
 | 
			
		||||
                  ...filter,
 | 
			
		||||
                  showOrphanedStacks: e.target.checked,
 | 
			
		||||
                }));
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          </Authorized>
 | 
			
		||||
        )}
 | 
			
		||||
        <TableSettingsMenuAutoRefresh
 | 
			
		||||
          value={tableState.autoRefreshRate}
 | 
			
		||||
          onChange={(value) => tableState.setAutoRefreshRate(value)}
 | 
			
		||||
        />
 | 
			
		||||
      </TableSettingsMenu>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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 (
 | 
			
		||||
      <Icon
 | 
			
		||||
        icon={Loader2}
 | 
			
		||||
        size="sm"
 | 
			
		||||
        className="!mr-1 animate-spin-slow align-middle"
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return <Icon icon={statusIcon(data)} className="!mr-1 align-middle" />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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,
 | 
			
		||||
    }
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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<DecoratedStack, unknown>) {
 | 
			
		||||
  if (isRegularStack(item)) {
 | 
			
		||||
    return <>Total</>;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (isExternalStack(item)) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Warning tooltip="This stack was created outside of Portainer. Control over this stack is limited.">
 | 
			
		||||
        Limited
 | 
			
		||||
      </Warning>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (isOrphanedStack(item)) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Warning tooltip="This stack was created inside an environment that is no longer registered inside Portainer.">
 | 
			
		||||
        Orphaned
 | 
			
		||||
      </Warning>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Warning({
 | 
			
		||||
  tooltip,
 | 
			
		||||
  children,
 | 
			
		||||
}: PropsWithChildren<{ tooltip: string }>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <TooltipWithChildren message={tooltip}>
 | 
			
		||||
      <span className="flex items-center gap-2">
 | 
			
		||||
        {children}
 | 
			
		||||
        <Icon icon={AlertCircle} mode="warning" />
 | 
			
		||||
      </span>
 | 
			
		||||
    </TooltipWithChildren>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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 <div className="text-center">-</div>;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (item.GitConfig) {
 | 
			
		||||
        return (
 | 
			
		||||
          <div className="text-center">
 | 
			
		||||
            <a
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              href={`${item.GitConfig.URL}/commit/${item.GitConfig.ConfigHash}`}
 | 
			
		||||
              rel="noreferrer"
 | 
			
		||||
            >
 | 
			
		||||
              {item.GitConfig.ConfigHash.slice(0, 7)}
 | 
			
		||||
            </a>
 | 
			
		||||
          </div>
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return <div className="text-center">{item.StackFileVersion || '-'}</div>;
 | 
			
		||||
    },
 | 
			
		||||
    meta: {
 | 
			
		||||
      className: '[&>*]:justify-center',
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			@ -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<ImageStatus>(
 | 
			
		||||
      `/stacks/${id}/images_status`
 | 
			
		||||
    );
 | 
			
		||||
    return data;
 | 
			
		||||
  } catch (e) {
 | 
			
		||||
    return {
 | 
			
		||||
      Status: 'unknown',
 | 
			
		||||
      Message: `Unable to retrieve image status for stack: ${id}`,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
import { createColumnHelper } from '@tanstack/react-table';
 | 
			
		||||
 | 
			
		||||
import { DecoratedStack } from '../types';
 | 
			
		||||
 | 
			
		||||
export const columnHelper = createColumnHelper<DecoratedStack>();
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
      <ImageUpToDateTooltip />
 | 
			
		||||
    </>
 | 
			
		||||
  ),
 | 
			
		||||
  cell: Cell,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function Cell({
 | 
			
		||||
  row: { original: item },
 | 
			
		||||
}: CellContext<DecoratedStack, unknown>) {
 | 
			
		||||
  const environmentId = useEnvironmentId();
 | 
			
		||||
 | 
			
		||||
  if (!isRegularStack(item)) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return <StackImageStatus environmentId={environmentId} stackId={item.Id} />;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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<DecoratedStack>(false),
 | 
			
		||||
  ]);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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<FilterOption>
 | 
			
		||||
  ) => {
 | 
			
		||||
    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<DecoratedStack, string>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <NameLink item={item} />
 | 
			
		||||
      {isRegularStack(item) && item.Status === 2 && (
 | 
			
		||||
        <span className="label label-warning image-tag space-left ml-2">
 | 
			
		||||
          Inactive
 | 
			
		||||
        </span>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function NameLink({ item }: { item: DecoratedStack }) {
 | 
			
		||||
  const { isAdmin } = useCurrentUser();
 | 
			
		||||
 | 
			
		||||
  const name = item.Name;
 | 
			
		||||
 | 
			
		||||
  if (isExternalStack(item)) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Link
 | 
			
		||||
        to="docker.stacks.stack"
 | 
			
		||||
        params={{
 | 
			
		||||
          name: item.Name,
 | 
			
		||||
          type: item.Type,
 | 
			
		||||
          external: true,
 | 
			
		||||
        }}
 | 
			
		||||
        title={name}
 | 
			
		||||
      >
 | 
			
		||||
        {name}
 | 
			
		||||
      </Link>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!isAdmin && isOrphanedStack(item)) {
 | 
			
		||||
    return <>{name}</>;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Link
 | 
			
		||||
      to="docker.stacks.stack"
 | 
			
		||||
      params={{
 | 
			
		||||
        name: item.Name,
 | 
			
		||||
        id: item.Id,
 | 
			
		||||
        type: item.Type,
 | 
			
		||||
        regular: item.Regular,
 | 
			
		||||
        orphaned: item.Orphaned,
 | 
			
		||||
        orphanedRunning: item.OrphanedRunning,
 | 
			
		||||
      }}
 | 
			
		||||
      title={name}
 | 
			
		||||
    >
 | 
			
		||||
      {name}
 | 
			
		||||
    </Link>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function Filter<TData extends { Used: boolean }>({
 | 
			
		||||
  column: { getFilterValue, setFilterValue, id },
 | 
			
		||||
}: {
 | 
			
		||||
  column: Column<TData>;
 | 
			
		||||
}) {
 | 
			
		||||
  const value = getFilterValue();
 | 
			
		||||
 | 
			
		||||
  const valueAsArray = getValueAsArrayOfStrings(value);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <MultipleSelectionFilter
 | 
			
		||||
      options={filterOptions}
 | 
			
		||||
      filterKey={id}
 | 
			
		||||
      value={valueAsArray}
 | 
			
		||||
      onChange={setFilterValue}
 | 
			
		||||
      menuTitle="Filter by activity"
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
export { StacksDatatable } from './StacksDatatable';
 | 
			
		||||
| 
						 | 
				
			
			@ -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<TableSettings>(storageKey, 'name', (set) => ({
 | 
			
		||||
    ...hiddenColumnsSettings(set),
 | 
			
		||||
    ...refreshableSettings(set),
 | 
			
		||||
    showOrphanedStacks: false,
 | 
			
		||||
    setShowOrphanedStacks(showOrphanedStacks) {
 | 
			
		||||
      set((s) => ({ ...s, showOrphanedStacks }));
 | 
			
		||||
    },
 | 
			
		||||
  }));
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
import { StackViewModel } from '../../view-models/stack';
 | 
			
		||||
import { ExternalStackViewModel } from '../../view-models/external-stack';
 | 
			
		||||
 | 
			
		||||
export type DecoratedStack = StackViewModel | ExternalStackViewModel;
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue