refactor(docker/volumes): migrate table to react [EE-4677] (#10312)

pull/10459/head
Chaim Lev-Ari 2023-10-11 10:27:42 +03:00 committed by GitHub
parent 8e1417b4e9
commit 5c37ed328f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 402 additions and 395 deletions

View File

@ -1,242 +0,0 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle vertical-center">
<div class="widget-icon space-right">
<pr-icon icon="$ctrl.titleIcon"></pr-icon>
</div>
{{ $ctrl.titleText }}
</div>
<div class="searchBar vertical-center">
<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 volume..."
auto-focus
ng-model-options="{ debounce: 300 }"
data-cy="volume-searchInput"
/>
</div>
<div class="actionBar !gap-3" authorization="DockerVolumeDelete, DockerVolumeCreate">
<button
type="button"
class="btn btn-sm btn-dangerlight vertical-center !ml-0 h-fit"
authorization="DockerVolumeDelete"
ng-disabled="$ctrl.state.selectedItemCount === 0"
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
data-cy="volume-removeVolumeButton"
>
<pr-icon icon="'trash-2'" class="leading-none"></pr-icon>Remove
</button>
<button
type="button"
class="btn btn-sm btn-primary vertical-center !ml-0 h-fit"
ui-sref="docker.volumes.new"
authorization="DockerVolumeCreate"
data-cy="volume-addVolumeButton"
>
<pr-icon icon="'plus'" class="leading-none"></pr-icon>Add volume
</button>
</div>
<div class="settings">
<span class="setting" 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">
<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 class="inline-block w-3">
<pr-icon id="refreshRateChange" icon="'check'" style="display: none" mode="'success'"></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 table-filters nowrap-cells table">
<thead>
<tr>
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open" class="flex gap-1">
<span class="md-checkbox" authorization="DockerVolumeDelete, DockerVolumeCreate">
<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 === 'Id'"
is-sorted-desc="$ctrl.state.orderBy === 'Id' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Id')"
></table-column-header>
<span uib-dropdown-toggle class="table-filter flex gap-1 self-end" ng-if="!$ctrl.filters.state.enabled">Filter <pr-icon icon="'filter'"></pr-icon></span>
<span uib-dropdown-toggle class="table-filter filter-active flex gap-1 self-end" ng-if="$ctrl.filters.state.enabled"
>Filter <pr-icon icon="'check'"></pr-icon
></span>
<div class="dropdown-menu" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader"> Filter by usage </div>
<div class="menuContent">
<div class="md-checkbox">
<input id="filter_usage_usedImages" type="checkbox" ng-model="$ctrl.filters.state.showUsedVolumes" ng-change="$ctrl.onstateFilterChange()" />
<label for="filter_usage_usedImages">Used volumes</label>
</div>
<div class="md-checkbox">
<input id="filter_usage_unusedImages" type="checkbox" ng-model="$ctrl.filters.state.showUnusedVolumes" ng-change="$ctrl.onstateFilterChange()" />
<label for="filter_usage_unusedImages">Unused volumes</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>
</th>
<th>
<table-column-header
col-title="'Stack'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'StackName'"
is-sorted-desc="$ctrl.state.orderBy === 'StackName' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('StackName')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Driver'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Driver'"
is-sorted-desc="$ctrl.state.orderBy === 'Driver' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Driver')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Mount point'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Mountpoint'"
is-sorted-desc="$ctrl.state.orderBy === 'Mountpoint' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Mountpoint')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Created'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'CreatedAt'"
is-sorted-desc="$ctrl.state.orderBy === 'CreatedAt' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('CreatedAt')"
></table-column-header>
</th>
<th ng-if="$ctrl.showHostColumn">
<table-column-header
col-title="'Host'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'NodeName'"
is-sorted-desc="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('NodeName')"
></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="DockerVolumeDelete, DockerVolumeCreate">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" />
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="docker.volumes.volume({ id: item.Id, nodeName: item.NodeName })" class="monospaced" title="{{ item.Id }}">{{ item.Id | truncate: 40 }}</a>
<button
ng-if="$ctrl.showBrowseAction"
type="button"
ui-sref="docker.volumes.volume.browse({ id: item.Id, nodeName: item.NodeName })"
class="btn btn-xs btn-primary space-left"
authorization="DockerAgentBrowseList"
>
<pr-icon icon="'search'"></pr-icon> browse
</button>
<span style="margin-left: 10px" class="label label-warning image-tag space-left" ng-if="item.dangling">Unused</span>
</td>
<td>{{ item.StackName ? item.StackName : '-' }}</td>
<td>{{ item.Driver }}</td>
<td>{{ item.Mountpoint | truncatelr }}</td>
<td>{{ item.CreatedAt | getisodate }}</td>
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
<td>
<span>
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = $ctrl.RCO.ADMINISTRATORS }}
</span>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="6" class="text-muted text-center">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="6" class="text-muted text-center">No volume available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline vertical-center">
<span class="limitSelector">
<span style="margin-right: 5px"> 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>

View File

@ -1,16 +0,0 @@
angular.module('portainer.docker').component('volumesDatatable', {
templateUrl: './volumesDatatable.html',
controller: 'VolumesDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
showHostColumn: '<',
removeAction: '<',
showBrowseAction: '<',
refreshCallback: '<',
},
});

View File

@ -1,71 +0,0 @@
angular.module('portainer.docker').controller('VolumesDatatableController', [
'$scope',
'$controller',
'DatatableService',
function ($scope, $controller, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
var ctrl = this;
this.filters = {
state: {
open: false,
enabled: false,
showUsedVolumes: true,
showUnusedVolumes: true,
},
};
this.applyFilters = function (value) {
var volume = value;
var filters = ctrl.filters;
if ((volume.dangling && filters.state.showUnusedVolumes) || (!volume.dangling && filters.state.showUsedVolumes)) {
return true;
}
return false;
};
this.onstateFilterChange = function () {
var filters = this.filters.state;
var filtered = false;
if (!filters.showUsedVolumes || !filters.showUnusedVolumes) {
filtered = true;
}
this.filters.state.enabled = filtered;
DatatableService.setDataTableFilters(this.tableKey, this.filters);
};
this.$onInit = function () {
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.onSettingsRepeaterChange();
};
},
]);

View File

@ -1,26 +0,0 @@
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
export function VolumeViewModel(data) {
this.Id = data.Name;
this.CreatedAt = data.CreatedAt;
this.Driver = data.Driver;
this.Options = data.Options;
this.Labels = data.Labels;
if (this.Labels && this.Labels['com.docker.compose.project']) {
this.StackName = this.Labels['com.docker.compose.project'];
} else if (this.Labels && this.Labels['com.docker.stack.namespace']) {
this.StackName = this.Labels['com.docker.stack.namespace'];
}
this.Mountpoint = data.Mountpoint;
this.ResourceId = data.ResourceID;
if (data.Portainer) {
if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
}
if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
}
}
}

View File

@ -0,0 +1,56 @@
import { Volume } from 'docker-types/generated/1.41';
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
import { IResource } from '@/react/docker/components/datatable/createOwnershipColumn';
import { PortainerMetadata } from '@/react/docker/types';
export class VolumeViewModel implements IResource {
Id: string;
CreatedAt: string | undefined;
Driver: string;
Options: Record<string, string>;
Labels: Record<string, string>;
StackName?: string;
Mountpoint: string;
ResourceId?: string;
NodeName?: string;
ResourceControl?: ResourceControlViewModel;
constructor(
data: Volume & { Portainer?: PortainerMetadata; ResourceID?: string }
) {
this.Id = data.Name;
this.CreatedAt = data.CreatedAt;
this.Driver = data.Driver;
this.Options = data.Options;
this.Labels = data.Labels;
if (this.Labels && this.Labels['com.docker.compose.project']) {
this.StackName = this.Labels['com.docker.compose.project'];
} else if (this.Labels && this.Labels['com.docker.stack.namespace']) {
this.StackName = this.Labels['com.docker.stack.namespace'];
}
this.Mountpoint = data.Mountpoint;
this.ResourceId = data.ResourceID;
if (data.Portainer) {
if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(
data.Portainer.ResourceControl
);
}
if (data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
}
}
}
}

View File

@ -28,6 +28,7 @@ import { containersModule } from './containers';
import { servicesModule } from './services'; import { servicesModule } from './services';
import { networksModule } from './networks'; import { networksModule } from './networks';
import { swarmModule } from './swarm'; import { swarmModule } from './swarm';
import { volumesModule } from './volumes';
const ngModule = angular const ngModule = angular
.module('portainer.docker.react.components', [ .module('portainer.docker.react.components', [
@ -35,6 +36,7 @@ const ngModule = angular
servicesModule, servicesModule,
networksModule, networksModule,
swarmModule, swarmModule,
volumesModule,
]) ])
.component('dockerfileDetails', r2a(DockerfileDetails, ['image'])) .component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
.component('dockerHealthStatus', r2a(HealthStatus, ['health'])) .component('dockerHealthStatus', r2a(HealthStatus, ['health']))

View File

@ -0,0 +1,18 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { VolumesDatatable } from '@/react/docker/volumes/ListView/VolumesDatatable';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
export const volumesModule = angular
.module('portainer.docker.react.components.volumes', [])
.component(
'volumesDatatable',
r2a(withUIRouter(withCurrentUser(VolumesDatatable)), [
'dataset',
'onRemove',
'onRefresh',
'isBrowseVisible',
])
).name;

View File

@ -1,17 +1,3 @@
<page-header title="'Volume list'" breadcrumbs="['Volumes']" reload="true"> </page-header> <page-header title="'Volume list'" breadcrumbs="['Volumes']" reload="true"> </page-header>
<div class="row"> <volumes-datatable dataset="volumes" on-remove="(removeAction)" on-refresh="(getVolumes)" is-browse-visible="showBrowseAction"></volumes-datatable>
<div class="col-sm-12">
<volumes-datatable
title-text="Volumes"
title-icon="database"
dataset="volumes"
table-key="volumes"
order-by="Id"
remove-action="removeAction"
show-host-column="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
show-browse-action="showBrowseAction"
refresh-callback="getVolumes"
></volumes-datatable>
</div>
</div>

View File

@ -1,13 +1,12 @@
import moment from 'moment'; import moment from 'moment';
import _ from 'lodash-es'; import _ from 'lodash-es';
import filesize from 'filesize'; import filesize from 'filesize';
import { Eye, EyeOff, Users, Cloud } from 'lucide-react'; import { Cloud } from 'lucide-react';
import Kube from '@/assets/ico/kube.svg?c'; import Kube from '@/assets/ico/kube.svg?c';
import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c'; import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
import MicrosoftIcon from '@/assets/ico/vendor/microsoft-icon.svg?c'; import MicrosoftIcon from '@/assets/ico/vendor/microsoft-icon.svg?c';
import NomadIcon from '@/assets/ico/vendor/nomad-icon.svg?c'; import NomadIcon from '@/assets/ico/vendor/nomad-icon.svg?c';
import { ResourceControlOwnership as RCO } from '@/react/portainer/access-control/types';
import { EnvironmentType } from '@/react/portainer/environments/types'; import { EnvironmentType } from '@/react/portainer/environments/types';
export function truncateLeftRight(text, max, left, right) { export function truncateLeftRight(text, max, left, right) {
@ -127,19 +126,6 @@ export function environmentTypeIcon(type) {
} }
} }
export function ownershipIcon(ownership) {
switch (ownership) {
case RCO.PRIVATE:
return EyeOff;
case RCO.ADMINISTRATORS:
return EyeOff;
case RCO.RESTRICTED:
return Users;
default:
return Eye;
}
}
export function truncate(text, length, end) { export function truncate(text, length, end) {
if (isNaN(length)) { if (isNaN(length)) {
length = 10; length = 10;

View File

@ -1,6 +1,7 @@
import angular from 'angular'; import angular from 'angular';
import _ from 'lodash-es'; import _ from 'lodash-es';
import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn';
import { import {
arrayToStr, arrayToStr,
environmentTypeIcon, environmentTypeIcon,
@ -12,7 +13,6 @@ import {
isoDate, isoDate,
isoDateFromTimestamp, isoDateFromTimestamp,
labelsToStr, labelsToStr,
ownershipIcon,
stripProtocol, stripProtocol,
truncate, truncate,
truncateLeftRight, truncateLeftRight,

View File

@ -1,10 +1,10 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { CellContext } from '@tanstack/react-table'; import { CellContext } from '@tanstack/react-table';
import { ownershipIcon } from '@/portainer/filters/filters';
import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
import { ContainerGroup } from '@/react/azure/types'; import { ContainerGroup } from '@/react/azure/types';
import { determineOwnership } from '@/react/portainer/access-control/models/ResourceControlViewModel'; import { determineOwnership } from '@/react/portainer/access-control/models/ResourceControlViewModel';
import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn';
import { columnHelper } from './helper'; import { columnHelper } from './helper';

View File

View File

@ -1,6 +1,6 @@
import { CellContext, ColumnDef } from '@tanstack/react-table'; import { CellContext, ColumnDef } from '@tanstack/react-table';
import { Eye, EyeOff, Users } from 'lucide-react';
import { ownershipIcon } from '@/portainer/filters/filters';
import { ResourceControlOwnership } from '@/react/portainer/access-control/types'; import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
@ -36,3 +36,16 @@ export function createOwnershipColumn<D extends IResource>(
); );
} }
} }
export function ownershipIcon(ownership: ResourceControlOwnership) {
switch (ownership) {
case ResourceControlOwnership.PRIVATE:
return EyeOff;
case ResourceControlOwnership.ADMINISTRATORS:
return EyeOff;
case ResourceControlOwnership.RESTRICTED:
return Users;
default:
return Eye;
}
}

View File

@ -0,0 +1,44 @@
import { Plus, Trash2 } from 'lucide-react';
import { Authorized } from '@/react/hooks/useUser';
import { Link } from '@@/Link';
import { Button } from '@@/buttons';
import { DecoratedVolume } from '../types';
export function TableActions({
selectedItems,
onRemove,
}: {
selectedItems: Array<DecoratedVolume>;
onRemove(items: Array<DecoratedVolume>): void;
}) {
return (
<div className="flex items-center gap-2">
<Authorized authorizations="DockerVolumeDelete">
<Button
color="dangerlight"
disabled={selectedItems.length === 0}
onClick={() => onRemove(selectedItems)}
icon={Trash2}
className="!m-0"
data-cy="volume-removeVolumeButton"
>
Remove
</Button>
</Authorized>
<Authorized authorizations="DockerVolumeCreate">
<Button
as={Link}
props={{ to: '.new' }}
icon={Plus}
className="!m-0"
data-cy="volume-addVolumeButton"
>
Add secret
</Button>
</Authorized>
</div>
);
}

View File

@ -0,0 +1,72 @@
import { Database } from 'lucide-react';
import { Datatable, TableSettingsMenu } from '@@/datatables';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import {
BasicTableSettings,
RefreshableTableSettings,
createPersistedStore,
refreshableSettings,
} from '@@/datatables/types';
import { useRepeater } from '@@/datatables/useRepeater';
import { useTableState } from '@@/datatables/useTableState';
import { withMeta } from '@@/datatables/extend-options/withMeta';
import { DecoratedVolume } from '../types';
import { TableActions } from './TableActions';
import { useColumns } from './columns';
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
const storageKey = 'docker-volumes';
const store = createPersistedStore<TableSettings>(
storageKey,
undefined,
(set) => ({
...refreshableSettings(set),
})
);
export function VolumesDatatable({
dataset,
onRemove,
onRefresh,
isBrowseVisible,
}: {
dataset?: Array<DecoratedVolume>;
onRemove(items: Array<DecoratedVolume>): void;
onRefresh(): Promise<void>;
isBrowseVisible: boolean;
}) {
const tableState = useTableState(store, storageKey);
useRepeater(tableState.autoRefreshRate, onRefresh);
const columns = useColumns();
return (
<Datatable
title="Volumes"
titleIcon={Database}
columns={columns}
dataset={dataset || []}
isLoading={!dataset}
settingsManager={tableState}
emptyContentLabel="No volume available."
renderTableActions={(selectedItems) => (
<TableActions selectedItems={selectedItems} onRemove={onRemove} />
)}
renderTableSettings={() => (
<TableSettingsMenu>
<TableSettingsMenuAutoRefresh
value={tableState.autoRefreshRate}
onChange={(value) => tableState.setAutoRefreshRate(value)}
/>
</TableSettingsMenu>
)}
extendTableOptions={withMeta({
table: 'volumes',
isBrowseVisible,
})}
/>
);
}

View File

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { DecoratedVolume } from '../../types';
export const columnHelper = createColumnHelper<DecoratedVolume>();

View File

@ -0,0 +1,48 @@
import _ from 'lodash';
import { useMemo } from 'react';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { createOwnershipColumn } from '@/react/docker/components/datatable/createOwnershipColumn';
import { isoDate, truncateLeftRight } from '@/portainer/filters/filters';
import { DecoratedVolume } from '../../types';
import { columnHelper } from './helper';
import { name } from './name';
export function useColumns() {
const environmentId = useEnvironmentId();
const isSwarm = useIsSwarm(environmentId);
return useMemo(
() =>
_.compact([
name,
columnHelper.accessor((item) => item.StackName || '-', {
header: 'Stack',
}),
columnHelper.accessor((item) => item.Driver, {
header: 'Driver',
}),
columnHelper.accessor((item) => item.Mountpoint, {
header: 'Mount point',
cell({ getValue }) {
return truncateLeftRight(getValue());
},
}),
columnHelper.accessor((item) => item.CreatedAt, {
header: 'Created',
cell({ getValue }) {
return isoDate(getValue());
},
}),
isSwarm &&
columnHelper.accessor((item) => item.NodeName || '-', {
header: 'Host',
}),
createOwnershipColumn<DecoratedVolume>(),
]),
[isSwarm]
);
}

View File

@ -0,0 +1,102 @@
import { CellContext, Column } from '@tanstack/react-table';
import { Search } from 'lucide-react';
import { truncate } from '@/portainer/filters/filters';
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
import { Authorized } from '@/react/hooks/useUser';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { MultipleSelectionFilter } from '@@/datatables/Filter';
import { DecoratedVolume } from '../../types';
import { getTableMeta } from '../tableMeta';
import { columnHelper } from './helper';
export const name = columnHelper.accessor('Id', {
id: 'name',
header: 'Name',
cell: Cell,
enableColumnFilter: true,
filterFn: (
{ original: { dangling } },
columnId,
filterValue: Array<'Used' | 'Unused'>
) => {
if (filterValue.length === 0) {
return true;
}
if (filterValue.includes('Used') && !dangling) {
return true;
}
if (filterValue.includes('Unused') && dangling) {
return true;
}
return false;
},
meta: {
filter: FilterByUsage,
},
});
function FilterByUsage<TData extends { Used: boolean }>({
column: { getFilterValue, setFilterValue, id },
}: {
column: Column<TData>;
}) {
const options = ['Used', 'Unused'];
const value = getFilterValue();
const valueAsArray = getValueAsArrayOfStrings(value);
return (
<MultipleSelectionFilter
options={options}
filterKey={id}
value={valueAsArray}
onChange={setFilterValue}
menuTitle="Filter by usage"
/>
);
}
function Cell({
getValue,
row: { original: item },
table: {
options: { meta },
},
}: CellContext<DecoratedVolume, string>) {
const { isBrowseVisible } = getTableMeta(meta);
const name = getValue();
return (
<>
<Link
to=".volume"
params={{
id: item.Id,
nodeName: item.NodeName,
}}
className="monospaced"
>
{truncate(name, 40)}
</Link>
{isBrowseVisible && (
<Authorized authorizations="DockerAgentBrowseList">
<Button icon={Search} color="primary" size="xsmall" as={Link}>
browse
</Button>
</Authorized>
)}
{item.dangling && (
<span className="label label-warning image-tag ml-2">Unused</span>
)}
</>
);
}

View File

@ -0,0 +1 @@
export { VolumesDatatable } from './VolumesDatatable';

View File

@ -0,0 +1,23 @@
import { TableMeta as BaseTableMeta } from '@tanstack/react-table';
import { VolumeViewModel } from '@/docker/models/volume';
interface TableMeta {
isBrowseVisible: boolean;
table: 'volumes';
}
function isTableMeta(meta: BaseTableMeta<VolumeViewModel>): meta is TableMeta {
return meta.table === 'volumes';
}
export function getTableMeta(meta?: BaseTableMeta<VolumeViewModel>): TableMeta {
if (!meta || !isTableMeta(meta)) {
return {
isBrowseVisible: false,
table: 'volumes',
};
}
return meta;
}

View File

@ -0,0 +1,3 @@
import { VolumeViewModel } from '@/docker/models/volume';
export type DecoratedVolume = VolumeViewModel & { dangling: boolean };

View File

@ -3,12 +3,13 @@ import { PropsWithChildren } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { ownershipIcon, truncate } from '@/portainer/filters/filters'; import { truncate } from '@/portainer/filters/filters';
import { UserId } from '@/portainer/users/types'; import { UserId } from '@/portainer/users/types';
import { TeamId } from '@/react/portainer/users/teams/types'; import { TeamId } from '@/react/portainer/users/teams/types';
import { useTeams } from '@/react/portainer/users/teams/queries'; import { useTeams } from '@/react/portainer/users/teams/queries';
import { useUsers } from '@/portainer/users/queries'; import { useUsers } from '@/portainer/users/queries';
import { pluralize } from '@/portainer/helpers/strings'; import { pluralize } from '@/portainer/helpers/strings';
import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';

View File

@ -2,8 +2,8 @@ import _ from 'lodash';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { buildOption } from '@/portainer/components/BoxSelector'; import { buildOption } from '@/portainer/components/BoxSelector';
import { ownershipIcon } from '@/portainer/filters/filters';
import { Team } from '@/react/portainer/users/teams/types'; import { Team } from '@/react/portainer/users/teams/types';
import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn';
import { BoxSelectorOption } from '@@/BoxSelector/types'; import { BoxSelectorOption } from '@@/BoxSelector/types';
import { BadgeIcon } from '@@/BadgeIcon'; import { BadgeIcon } from '@@/BadgeIcon';
@ -16,7 +16,7 @@ const publicOption: BoxSelectorOption<ResourceControlOwnership> = {
id: 'access_public', id: 'access_public',
description: description:
'I want any user with access to this environment to be able to manage this resource', 'I want any user with access to this environment to be able to manage this resource',
icon: <BadgeIcon icon={ownershipIcon('public')} />, icon: <BadgeIcon icon={ownershipIcon(ResourceControlOwnership.PUBLIC)} />,
}; };
export function useOptions( export function useOptions(
@ -41,14 +41,16 @@ function adminOptions() {
return [ return [
buildOption( buildOption(
'access_administrators', 'access_administrators',
<BadgeIcon icon={ownershipIcon('administrators')} />, <BadgeIcon
icon={ownershipIcon(ResourceControlOwnership.ADMINISTRATORS)}
/>,
'Administrators', 'Administrators',
'I want to restrict the management of this resource to administrators only', 'I want to restrict the management of this resource to administrators only',
ResourceControlOwnership.ADMINISTRATORS ResourceControlOwnership.ADMINISTRATORS
), ),
buildOption( buildOption(
'access_restricted', 'access_restricted',
<BadgeIcon icon={ownershipIcon('restricted')} />, <BadgeIcon icon={ownershipIcon(ResourceControlOwnership.RESTRICTED)} />,
'Restricted', 'Restricted',
'I want to restrict the management of this resource to a set of users and/or teams', 'I want to restrict the management of this resource to a set of users and/or teams',
ResourceControlOwnership.RESTRICTED ResourceControlOwnership.RESTRICTED
@ -59,7 +61,7 @@ function nonAdminOptions(teams?: Team[]) {
return _.compact([ return _.compact([
buildOption( buildOption(
'access_private', 'access_private',
<BadgeIcon icon={ownershipIcon('private')} />, <BadgeIcon icon={ownershipIcon(ResourceControlOwnership.PRIVATE)} />,
'Private', 'Private',
'I want to restrict this resource to be manageable by myself only', 'I want to restrict this resource to be manageable by myself only',
ResourceControlOwnership.PRIVATE ResourceControlOwnership.PRIVATE
@ -68,7 +70,7 @@ function nonAdminOptions(teams?: Team[]) {
teams.length > 0 && teams.length > 0 &&
buildOption( buildOption(
'access_restricted', 'access_restricted',
<BadgeIcon icon={ownershipIcon('restricted')} />, <BadgeIcon icon={ownershipIcon(ResourceControlOwnership.RESTRICTED)} />,
'Restricted', 'Restricted',
teams.length === 1 ? ( teams.length === 1 ? (
<> <>