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 { networksModule } from './networks';
import { swarmModule } from './swarm';
import { volumesModule } from './volumes';
const ngModule = angular
.module('portainer.docker.react.components', [
@ -35,6 +36,7 @@ const ngModule = angular
servicesModule,
networksModule,
swarmModule,
volumesModule,
])
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
.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>
<div class="row">
<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>
<volumes-datatable dataset="volumes" on-remove="(removeAction)" on-refresh="(getVolumes)" is-browse-visible="showBrowseAction"></volumes-datatable>

View File

@ -1,13 +1,12 @@
import moment from 'moment';
import _ from 'lodash-es';
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 DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
import MicrosoftIcon from '@/assets/ico/vendor/microsoft-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';
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) {
if (isNaN(length)) {
length = 10;

View File

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

View File

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

View File

View File

@ -1,6 +1,6 @@
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 { 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 { Info } from 'lucide-react';
import { ownershipIcon, truncate } from '@/portainer/filters/filters';
import { truncate } from '@/portainer/filters/filters';
import { UserId } from '@/portainer/users/types';
import { TeamId } from '@/react/portainer/users/teams/types';
import { useTeams } from '@/react/portainer/users/teams/queries';
import { useUsers } from '@/portainer/users/queries';
import { pluralize } from '@/portainer/helpers/strings';
import { ownershipIcon } from '@/react/docker/components/datatable/createOwnershipColumn';
import { Link } from '@@/Link';
import { Tooltip } from '@@/Tip/Tooltip';

View File

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