Compare commits

...

9 Commits

Author SHA1 Message Date
Chaim Lev-Ari
8ab739adfd refactor(docker/services): convert service tasks table to react [EE-4337]
close [EE-4337]
2023-08-28 14:40:58 +02:00
Chaim Lev-Ari
0ee6c5c6e9 refactor(ui/datatables): allow to control selected state from parent 2023-08-27 12:32:31 +02:00
Chaim Lev-Ari
1e2dbd7778 refactor(ui/tables): remove temp type 2023-08-27 12:32:31 +02:00
Chaim Lev-Ari
92dd6ed7bc refactor(ui/datatables): allow for not sort 2023-08-27 12:32:31 +02:00
Chaim Lev-Ari
f780207b82 feat(ui/datatables): support meta for expandable table 2023-08-27 12:32:31 +02:00
Chaim Lev-Ari
86a848d927 feat(ui/datatables): fix filter style 2023-08-27 12:32:31 +02:00
Chaim Lev-Ari
ca4130b221 fix(ui/datatables): simplify getRowId 2023-08-27 12:32:31 +02:00
Chaim Lev-Ari
bb7c6077d5 feat(ui/datatables): allow more global filters 2023-08-27 12:32:31 +02:00
Chaim Lev-Ari
bccab06abb refactor(ui/datatables): use object type for table data 2023-08-27 12:32:31 +02:00
57 changed files with 710 additions and 465 deletions

View File

@@ -1,109 +0,0 @@
<div class="inner-datatable">
<table class="table-condensed table-hover nowrap-cells table">
<thead>
<tr>
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open" class="w-[10%]">
<div class="flex">
<table-column-header
col-title="'Status'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Status.State'"
is-sorted-desc="$ctrl.state.orderBy === 'Status.State' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Status.State')"
></table-column-header>
<span class="space-left">
<span uib-dropdown-toggle class="table-filter" ng-if="!$ctrl.filters.state.enabled"
>Filter
<pr-icon icon="'filter'"></pr-icon>
</span>
<span uib-dropdown-toggle class="table-filter filter-active" ng-if="$ctrl.filters.state.enabled"
>Filter
<pr-icon icon="'check'"></pr-icon>
</span>
</span>
<div class="dropdown-menu" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader"> Filter by state </div>
<div class="menuContent">
<div class="md-checkbox" ng-repeat="filter in $ctrl.filters.state.values track by $index">
<input id="filter_state_{{ $ctrl.serviceId }}_{{ $index }}" type="checkbox" ng-model="filter.display" ng-change="$ctrl.onStateFilterChange()" />
<label for="filter_state_{{ $ctrl.serviceId }}_{{ $index }}">{{ filter.label }}</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 style="width: 22%">Task</th>
<th>Actions</th>
<th>
<table-column-header
col-title="'Slot'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Slot'"
is-sorted-desc="$ctrl.state.orderBy === 'Slot' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Slot')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Node'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'NodeId'"
is-sorted-desc="$ctrl.state.orderBy === 'NodeId' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('NodeId')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Last Update'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Updated'"
is-sorted-desc="$ctrl.state.orderBy === 'Updated' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Updated')"
></table-column-header>
</th>
</tr>
</thead>
<tbody>
<tr
ng-repeat="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder))"
>
<td class="text-center">
<span class="label label-{{ item.Status.State | taskstatusbadge }} space-right">{{ item.Status.State }}</span>
</td>
<td>
<a ng-if="!$ctrl.agentProxy || !item.Container" ui-sref="docker.tasks.task({id: item.Id})" class="monospaced">{{ item.Id }}</a>
<a ng-if="$ctrl.agentProxy && item.Container" ui-sref="docker.containers.container({ id: item.Container.Id, nodeName: item.Container.NodeName })" class="monospaced">{{
item.Id
}}</a>
</td>
<td>
<container-quick-actions
ng-if="!$ctrl.agentProxy || !item.Container"
container-id="item.ContainerId"
task-id="item.Id"
status="item.Status.State"
state="$ctrl.state"
></container-quick-actions>
<container-quick-actions
ng-if="$ctrl.agentProxy && item.Container"
container-id="item.Container.Id"
node-name="item.Container.NodeName"
status="item.Status.State"
state="$ctrl.state"
></container-quick-actions>
</td>
<td>{{ item.Slot ? item.Slot : '-' }}</td>
<td>{{ item.NodeId | tasknodename : $ctrl.nodes }}</td>
<td>{{ item.Updated | getisodate }}</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="5" class="text-muted text-center">No task matching filter.</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -1,15 +0,0 @@
angular.module('portainer.docker').component('serviceTasksDatatable', {
templateUrl: './serviceTasksDatatable.html',
controller: 'ServiceTasksDatatableController',
bindings: {
dataset: '<',
serviceId: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
nodes: '<',
agentProxy: '<',
textFilter: '=',
showTaskLogsButton: '<',
},
});

View File

@@ -1,94 +0,0 @@
import _ from 'lodash-es';
angular.module('portainer.docker').controller('ServiceTasksDatatableController', [
'$scope',
'$controller',
'DatatableService',
function ($scope, $controller, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
var ctrl = this;
this.state = Object.assign(this.state, {
showQuickActionStats: true,
showQuickActionLogs: true,
showQuickActionConsole: true,
showQuickActionInspect: true,
showQuickActionExec: true,
showQuickActionAttach: false,
});
this.filters = {
state: {
open: false,
enabled: false,
values: [],
},
};
this.applyFilters = function (item) {
var filters = ctrl.filters;
for (var i = 0; i < filters.state.values.length; i++) {
var filter = filters.state.values[i];
if (item.Status.State === filter.label && filter.display) {
return true;
}
}
return false;
};
this.onStateFilterChange = function () {
var filters = this.filters.state.values;
var filtered = false;
for (var i = 0; i < filters.length; i++) {
var filter = filters[i];
if (!filter.display) {
filtered = true;
}
}
this.filters.state.enabled = filtered;
};
this.prepareTableFromDataset = function () {
var availableStateFilters = [];
for (var i = 0; i < this.dataset.length; i++) {
var item = this.dataset[i];
availableStateFilters.push({ label: item.Status.State, display: true });
}
this.filters.state.values = _.uniqBy(availableStateFilters, 'label');
};
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

@@ -231,16 +231,7 @@
<tr dir-paginate-end ng-show="item.Expanded">
<td></td>
<td colspan="8">
<service-tasks-datatable
dataset="item.Tasks"
service-id="item.Id"
table-key="service-tasks"
order-by="Status.State"
nodes="$ctrl.nodes"
agent-proxy="$ctrl.agentProxy"
show-task-logs-button="$ctrl.showTaskLogsButton"
text-filter="$ctrl.state.textFilter"
></service-tasks-datatable>
<docker-service-tasks-datatable dataset="item.Tasks" search="$ctrl.state.textFilter"></docker-service-tasks-datatable>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">

View File

@@ -89,13 +89,7 @@
>
</td>
<td>
<container-quick-actions
ng-if="!$ctrl.agentProxy || !item.Container"
container-id="item.ContainerId"
task-id="item.Id"
status="item.Status.State"
state="$ctrl.state"
></container-quick-actions>
<task-table-quick-actions ng-if="!$ctrl.agentProxy || !item.Container" task-id="item.Id" state="$ctrl.state"></task-table-quick-actions>
<container-quick-actions
ng-if="$ctrl.agentProxy && item.Container"
container-id="item.Container.Id"

View File

@@ -1,5 +1,5 @@
import _ from 'lodash-es';
import { joinCommand, trimSHA } from './utils';
import { joinCommand, taskStatusBadge, trimSHA } from './utils';
function includeString(text, values) {
return values.some(function (val) {
@@ -49,22 +49,7 @@ angular
})
.filter('taskstatusbadge', function () {
'use strict';
return function (text) {
var status = _.toLower(text);
var labelStyle = 'default';
if (includeString(status, ['new', 'allocated', 'assigned', 'accepted', 'preparing', 'ready', 'starting', 'remove'])) {
labelStyle = 'info';
} else if (includeString(status, ['pending'])) {
labelStyle = 'warning';
} else if (includeString(status, ['shutdown', 'failed', 'rejected', 'orphaned'])) {
labelStyle = 'danger';
} else if (includeString(status, ['complete'])) {
labelStyle = 'primary';
} else if (includeString(status, ['running'])) {
labelStyle = 'success';
}
return labelStyle;
};
return taskStatusBadge;
})
.filter('taskhaslogs', function () {
'use strict';

View File

@@ -1,4 +1,5 @@
import _ from 'lodash';
import { TaskState } from 'docker-types/generated/1.41';
export function trimSHA(imageName: string) {
if (!imageName) {
@@ -17,3 +18,38 @@ export function joinCommand(command: null | Array<string> = []) {
return command.join(' ');
}
export function taskStatusBadge(text?: TaskState) {
const status = _.toLower(text);
if (
[
'new',
'allocated',
'assigned',
'accepted',
'preparing',
'ready',
'starting',
'remove',
].includes(status)
) {
return 'info';
}
if (['pending'].includes(status)) {
return 'warning';
}
if (['shutdown', 'failed', 'rejected', 'orphaned'].includes(status)) {
return 'danger';
}
if (['complete'].includes(status)) {
return 'primary';
}
if (['running'].includes(status)) {
return 'success';
}
return 'default';
}

View File

@@ -1,14 +0,0 @@
export function TaskViewModel(data) {
this.Id = data.ID;
this.Created = data.CreatedAt;
this.Updated = data.UpdatedAt;
this.Slot = data.Slot;
this.Spec = data.Spec;
this.Status = data.Status;
this.DesiredState = data.DesiredState;
this.ServiceId = data.ServiceID;
this.NodeId = data.NodeID;
if (data.Status && data.Status.ContainerStatus && data.Status.ContainerStatus.ContainerID) {
this.ContainerId = data.Status.ContainerStatus.ContainerID;
}
}

36
app/docker/models/task.ts Normal file
View File

@@ -0,0 +1,36 @@
import { Task, TaskSpec, TaskState } from 'docker-types/generated/1.41';
export class TaskViewModel {
Id: string;
Created: string;
Updated: string;
Slot: number;
Spec?: TaskSpec;
Status: Task['Status'];
DesiredState: TaskState;
ServiceId: string;
NodeId: string;
ContainerId: string = '';
constructor(data: Task) {
this.Id = data.ID || '';
this.Created = data.CreatedAt || '';
this.Updated = data.UpdatedAt || '';
this.Slot = data.Slot || 0;
this.Spec = data.Spec;
this.Status = data.Status;
this.DesiredState = data.DesiredState || 'pending';
this.ServiceId = data.ServiceID || '';
this.NodeId = data.NodeID || '';
this.ContainerId = data.Status?.ContainerStatus?.ContainerID || '';
}
}

View File

@@ -21,8 +21,10 @@ import { ConfigsDatatable } from '@/react/docker/configs/ListView/ConfigsDatatab
import { AgentHostBrowser } from '@/react/docker/host/BrowseView/AgentHostBrowser';
import { AgentVolumeBrowser } from '@/react/docker/volumes/BrowseView/AgentVolumeBrowser';
import { servicesModule } from './services';
const ngModule = angular
.module('portainer.docker.react.components', [])
.module('portainer.docker.react.components', [servicesModule])
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
.component(
@@ -32,7 +34,6 @@ const ngModule = angular
'nodeName',
'state',
'status',
'taskId',
])
)
.component('templateListDropdown', TemplateListDropdownAngular)

View File

@@ -0,0 +1,21 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { TasksDatatable } from '@/react/docker/services/ListView/ServicesDatatable/TasksDatatable';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { TaskTableQuickActions } from '@/react/docker/services/common/TaskTableQuickActions';
export const servicesModule = angular
.module('portainer.docker.react.components.services', [])
.component(
'dockerServiceTasksDatatable',
r2a(withUIRouter(withCurrentUser(TasksDatatable)), ['dataset', 'search'])
)
.component(
'dockerTaskTableQuickActions',
r2a(withUIRouter(withCurrentUser(TaskTableQuickActions)), [
'state',
'taskId',
])
).name;

View File

@@ -14,6 +14,8 @@ import {
getExpandedRowModel,
TableOptions,
TableMeta,
Updater,
RowSelectionState,
} from '@tanstack/react-table';
import { ReactNode, useMemo } from 'react';
import clsx from 'clsx';
@@ -28,7 +30,7 @@ import { DatatableFooter } from './DatatableFooter';
import { defaultGetRowId } from './defaultGetRowId';
import { Table } from './Table';
import { useGoToHighlightedRow } from './useGoToHighlightedRow';
import { BasicTableSettings } from './types';
import { BasicTableSettings, DefaultType } from './types';
import { DatatableContent } from './DatatableContent';
import { createSelectColumn } from './select-column';
import { TableRow } from './TableRow';
@@ -48,9 +50,12 @@ export type PaginationProps =
onPageChange(page: number): void;
};
type DefaultGlobalFilter = { search: string };
export interface Props<
D extends Record<string, unknown>,
TMeta extends TableMeta<D> = TableMeta<D>
D extends DefaultType,
TMeta extends TableMeta<D> = TableMeta<D>,
TFilter extends DefaultGlobalFilter = DefaultGlobalFilter
> extends AutomationTestingProps {
dataset: D[];
columns: TableOptions<D>['columns'];
@@ -71,11 +76,19 @@ export interface Props<
getRowCanExpand?(row: Row<D>): boolean;
noWidget?: boolean;
meta?: TMeta;
globalFilterFn?: typeof defaultGlobalFilterFn<D, TFilter>;
/**
* pass selectedItemIds and onChangeSelectedItems to control selected values from the parent
* usually useful when the table is used in a form
*/
selectedItemIds?: Array<string>;
onChangeSelectedItems?(value: Array<string>): void;
}
export function Datatable<
D extends Record<string, unknown>,
TMeta extends TableMeta<D> = TableMeta<D>
D extends DefaultType,
TMeta extends TableMeta<D> = TableMeta<D>,
TFilter extends DefaultGlobalFilter = DefaultGlobalFilter
>({
columns,
dataset,
@@ -101,7 +114,10 @@ export function Datatable<
page,
totalCount = dataset.length,
isServerSidePagination = false,
}: Props<D, TMeta> & PaginationProps) {
globalFilterFn = defaultGlobalFilterFn,
selectedItemIds,
onChangeSelectedItems,
}: Props<D, TMeta, TFilter> & PaginationProps) {
const pageCount = useMemo(
() => Math.ceil(totalCount / settings.pageSize),
[settings.pageSize, totalCount]
@@ -126,7 +142,10 @@ export function Datatable<
pageIndex: page || 0,
},
sorting: settings.sortBy ? [settings.sortBy] : [],
globalFilter: settings.search,
globalFilter: {
search: settings.search,
...initialTableState.globalFilter,
},
...initialTableState,
},
@@ -135,6 +154,7 @@ export function Datatable<
enableHiding: true,
sortingFn: 'alphanumeric',
},
...getControlledSelectionState(onChangeSelectedItems, selectedItemIds),
enableRowSelection,
autoResetExpanded: false,
globalFilterFn,
@@ -201,9 +221,9 @@ export function Datatable<
</Table.Container>
);
function handleSearchBarChange(value: string) {
tableInstance.setGlobalFilter(value);
settings.setSearch(value);
function handleSearchBarChange(search: string) {
tableInstance.setGlobalFilter({ search });
settings.setSearch(search);
}
function handlePageChange(page: number) {
@@ -221,7 +241,33 @@ export function Datatable<
}
}
function defaultRenderRow<D extends Record<string, unknown>>(
function getControlledSelectionState(
onChange?: (value: string[]) => void,
value?: string[]
) {
if (!onChange || !value) {
return {};
}
return {
state: {
rowSelection: Object.fromEntries(value.map((i) => [i, true])),
},
onRowSelectionChange(updater: Updater<RowSelectionState>) {
const newValue =
typeof updater !== 'function'
? updater
: updater(Object.fromEntries(value.map((i) => [i, true])));
onChange(
Object.entries(newValue)
.filter(([, selected]) => selected)
.map(([id]) => id)
);
},
};
}
function defaultRenderRow<D extends DefaultType>(
row: Row<D>,
highlightedItemId?: string
) {
@@ -235,7 +281,7 @@ function defaultRenderRow<D extends Record<string, unknown>>(
);
}
function getIsSelectionEnabled<D extends Record<string, unknown>>(
function getIsSelectionEnabled<D extends DefaultType>(
disabledSelect?: boolean,
isRowSelectable?: Props<D>['isRowSelectable']
) {
@@ -250,14 +296,14 @@ function getIsSelectionEnabled<D extends Record<string, unknown>>(
return true;
}
function globalFilterFn<D>(
export function defaultGlobalFilterFn<D, TFilter extends { search: string }>(
row: Row<D>,
columnId: string,
filterValue: null | string
filterValue: null | TFilter
): boolean {
const value = row.getValue(columnId);
if (filterValue === null || filterValue === '') {
if (filterValue === null || !filterValue.search) {
return true;
}
@@ -265,7 +311,7 @@ function globalFilterFn<D>(
return false;
}
const filterValueLower = filterValue.toLowerCase();
const filterValueLower = filterValue.search.toLowerCase();
if (
typeof value === 'string' ||

View File

@@ -3,9 +3,9 @@ import { Row, Table as TableInstance } from '@tanstack/react-table';
import { AutomationTestingProps } from '@/types';
import { Table } from './Table';
import { DefaultType } from './types';
interface Props<D extends Record<string, unknown>>
extends AutomationTestingProps {
interface Props<D extends DefaultType> extends AutomationTestingProps {
tableInstance: TableInstance<D>;
renderRow(row: Row<D>): React.ReactNode;
onSortChange?(colId: string, desc: boolean): void;
@@ -13,7 +13,7 @@ interface Props<D extends Record<string, unknown>>
emptyContentLabel?: string;
}
export function DatatableContent<D extends Record<string, unknown>>({
export function DatatableContent<D extends DefaultType>({
tableInstance,
renderRow,
onSortChange,

View File

@@ -1,4 +1,4 @@
import { Row } from '@tanstack/react-table';
import { Row, TableMeta } from '@tanstack/react-table';
import { ReactNode } from 'react';
import { ExpandableDatatableTableRow } from './ExpandableDatatableRow';
@@ -7,21 +7,27 @@ import {
Props as DatatableProps,
PaginationProps,
} from './Datatable';
import { DefaultType } from './types';
interface Props<D extends Record<string, unknown>>
extends Omit<DatatableProps<D>, 'renderRow' | 'expandable'> {
interface Props<
D extends DefaultType,
TMeta extends TableMeta<D> = TableMeta<D>
> extends Omit<DatatableProps<D, TMeta>, 'renderRow' | 'expandable'> {
renderSubRow(row: Row<D>): ReactNode;
expandOnRowClick?: boolean;
}
export function ExpandableDatatable<D extends Record<string, unknown>>({
export function ExpandableDatatable<
D extends DefaultType,
TMeta extends TableMeta<D> = TableMeta<D>
>({
renderSubRow,
getRowCanExpand = () => true,
expandOnRowClick,
...props
}: Props<D> & PaginationProps) {
}: Props<D, TMeta> & PaginationProps) {
return (
<Datatable<D>
<Datatable<D, TMeta>
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
getRowCanExpand={getRowCanExpand}

View File

@@ -2,15 +2,16 @@ import { ReactNode } from 'react';
import { Row } from '@tanstack/react-table';
import { TableRow } from './TableRow';
import { DefaultType } from './types';
interface Props<D extends Record<string, unknown>> {
interface Props<D extends DefaultType> {
row: Row<D>;
disableSelect?: boolean;
renderSubRow(row: Row<D>): ReactNode;
expandOnClick?: boolean;
}
export function ExpandableDatatableTableRow<D extends Record<string, unknown>>({
export function ExpandableDatatableTableRow<D extends DefaultType>({
row,
disableSelect,
renderSubRow,

View File

@@ -8,8 +8,10 @@ import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
import { Icon } from '@@/Icon';
import { DefaultType } from './types';
interface MultipleSelectionFilterProps {
options: string[];
options: Array<string> | ReadonlyArray<string>;
value: string[];
filterKey: string;
onChange: (value: string[]) => void;
@@ -28,12 +30,12 @@ export function MultipleSelectionFilter({
<div>
<Menu>
<MenuButton
className={clsx('table-filter', { 'filter-active': enabled })}
className={clsx('table-filter flex items-center gap-1', {
'filter-active': enabled,
})}
>
<div className="flex items-center gap-1">
Filter
<Icon icon={enabled ? Check : Filter} />
</div>
Filter
<Icon icon={enabled ? Check : Filter} />
</MenuButton>
<MenuPopover className="dropdown-menu">
<div className="tableMenu">
@@ -70,9 +72,7 @@ export function MultipleSelectionFilter({
}
}
export function filterHOC<TData extends Record<string, unknown>>(
menuTitle: string
) {
export function filterHOC<TData extends DefaultType>(menuTitle: string) {
return function Filter({
column: { getFilterValue, setFilterValue, getFacetedRowModel, id },
}: {

View File

@@ -12,9 +12,9 @@ import { defaultGetRowId } from './defaultGetRowId';
import { Table } from './Table';
import { NestedTable } from './NestedTable';
import { DatatableContent } from './DatatableContent';
import { BasicTableSettings } from './types';
import { BasicTableSettings, DefaultType } from './types';
interface Props<D extends Record<string, unknown>> {
interface Props<D extends DefaultType> {
dataset: D[];
columns: TableOptions<D>['columns'];
@@ -23,9 +23,14 @@ interface Props<D extends Record<string, unknown>> {
initialTableState?: Partial<TableState>;
isLoading?: boolean;
initialSortBy?: BasicTableSettings['sortBy'];
/**
* keyword to filter by
*/
search?: string;
}
export function NestedDatatable<D extends Record<string, unknown>>({
export function NestedDatatable<D extends DefaultType>({
columns,
dataset,
getRowId = defaultGetRowId,
@@ -33,6 +38,7 @@ export function NestedDatatable<D extends Record<string, unknown>>({
initialTableState = {},
isLoading,
initialSortBy,
search,
}: Props<D>) {
const tableInstance = useReactTable<D>({
columns,
@@ -45,6 +51,9 @@ export function NestedDatatable<D extends Record<string, unknown>>({
enableColumnFilter: false,
enableHiding: false,
},
state: {
globalFilter: search,
},
getRowId,
autoResetExpanded: false,
getCoreRowModel: getCoreRowModel(),
@@ -55,7 +64,7 @@ export function NestedDatatable<D extends Record<string, unknown>>({
return (
<NestedTable>
<Table.Container>
<Table.Container noWidget>
<DatatableContent<D>
tableInstance={tableInstance}
isLoading={isLoading}

View File

@@ -1,16 +1,16 @@
import { Fragment, PropsWithChildren } from 'react';
import { Row } from '@tanstack/react-table';
interface Props<T extends Record<string, unknown> = Record<string, unknown>> {
import { DefaultType } from './types';
interface Props<T extends DefaultType = DefaultType> {
isLoading?: boolean;
rows: Row<T>[];
emptyContent?: string;
renderRow(row: Row<T>): React.ReactNode;
}
export function TableContent<
T extends Record<string, unknown> = Record<string, unknown>
>({
export function TableContent<T extends DefaultType = DefaultType>({
isLoading = false,
rows,
emptyContent = 'No items available',

View File

@@ -2,15 +2,17 @@ import { Header, flexRender } from '@tanstack/react-table';
import { filterHOC } from './Filter';
import { TableHeaderCell } from './TableHeaderCell';
import { DefaultType } from './types';
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
interface Props<D extends DefaultType = DefaultType> {
headers: Header<D, unknown>[];
onSortChange?(colId: string, desc: boolean): void;
}
export function TableHeaderRow<
D extends Record<string, unknown> = Record<string, unknown>
>({ headers, onSortChange }: Props<D>) {
export function TableHeaderRow<D extends DefaultType = DefaultType>({
headers,
onSortChange,
}: Props<D>) {
return (
<tr>
{headers.map((header) => {

View File

@@ -1,15 +1,19 @@
import { Cell, flexRender } from '@tanstack/react-table';
import clsx from 'clsx';
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
import { DefaultType } from './types';
interface Props<D extends DefaultType = DefaultType> {
cells: Cell<D, unknown>[];
className?: string;
onClick?: () => void;
}
export function TableRow<
D extends Record<string, unknown> = Record<string, unknown>
>({ cells, className, onClick }: Props<D>) {
export function TableRow<D extends DefaultType = DefaultType>({
cells,
className,
onClick,
}: Props<D>) {
return (
<tr
className={clsx(className, { 'cursor-pointer': !!onClick })}

View File

@@ -2,13 +2,16 @@ import { ColumnDef, CellContext } from '@tanstack/react-table';
import { Link } from '@@/Link';
export function buildNameColumn<T extends Record<string, unknown>>(
import { DefaultType } from './types';
import { defaultGetRowId } from './defaultGetRowId';
export function buildNameColumn<T extends DefaultType>(
nameKey: keyof T,
idKey: string,
path: string,
idParam = 'id'
idParam = 'id',
idGetter: (row: T) => string = defaultGetRowId<T>
): ColumnDef<T> {
const cell = createCell<T>();
const cell = createCell();
return {
header: 'Name',
@@ -19,7 +22,7 @@ export function buildNameColumn<T extends Record<string, unknown>>(
enableHiding: false,
};
function createCell<T extends Record<string, unknown>>() {
function createCell() {
return function NameCell({ renderValue, row }: CellContext<T, unknown>) {
const name = renderValue() || '';
@@ -30,7 +33,7 @@ export function buildNameColumn<T extends Record<string, unknown>>(
return (
<Link
to={path}
params={{ [idParam]: row.original[idKey] }}
params={{ [idParam]: idGetter(row.original) }}
title={name}
>
{name}

View File

@@ -1,16 +1,17 @@
export function defaultGetRowId<D extends Record<string, unknown>>(
row: D
): string {
if (row.id && (typeof row.id === 'string' || typeof row.id === 'number')) {
return row.id.toString();
}
import { DefaultType } from './types';
if (row.Id && (typeof row.Id === 'string' || typeof row.Id === 'number')) {
return row.Id.toString();
}
/**
* gets row id by looking for one of id, Id, or ID keys on the object
*/
export function defaultGetRowId<D extends DefaultType>(row: D): string {
const key = ['id', 'Id', 'ID'].find((key) =>
Object.hasOwn(row, key)
) as keyof D;
if (row.ID && (typeof row.ID === 'string' || typeof row.ID === 'number')) {
return row.ID.toString();
const value = row[key];
if (typeof value === 'string' || typeof value === 'number') {
return value.toString();
}
return '';

View File

@@ -3,9 +3,9 @@ import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@@/buttons';
export function buildExpandColumn<
T extends Record<string, unknown>
>(): ColumnDef<T> {
import { DefaultType } from './types';
export function buildExpandColumn<T extends DefaultType>(): ColumnDef<T> {
return {
id: 'expand',
header: ({ table }) => {

View File

@@ -1,8 +1,12 @@
import { Row } from '@tanstack/react-table';
export function multiple<
D extends Record<string, unknown> = Record<string, unknown>
>({ getValue }: Row<D>, columnId: string, filterValue: string[]): boolean {
import { DefaultType } from './types';
export function multiple<D extends DefaultType = DefaultType>(
{ getValue }: Row<D>,
columnId: string,
filterValue: string[]
): boolean {
if (filterValue.length === 0) {
return true;
}

View File

@@ -3,6 +3,8 @@ import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
export type DefaultType = object;
export interface PaginationTableSettings {
pageSize: number;
setPageSize: (pageSize: number) => void;
@@ -23,21 +25,24 @@ export function paginationSettings<T extends PaginationTableSettings>(
}
export interface SortableTableSettings {
sortBy: { id: string; desc: boolean };
setSortBy: (id: string, desc: boolean) => void;
sortBy: { id: string; desc: boolean } | undefined;
setSortBy: (id: string | undefined, desc: boolean) => void;
}
export function sortableSettings<T extends SortableTableSettings>(
set: ZustandSetFunc<T>,
initialSortBy: string | { id: string; desc: boolean }
initialSortBy?: string | { id: string; desc: boolean }
): SortableTableSettings {
return {
sortBy:
typeof initialSortBy === 'string'
? { id: initialSortBy, desc: false }
: initialSortBy,
setSortBy: (id: string, desc: boolean) =>
set((s) => ({ ...s, sortBy: { id, desc } })),
setSortBy: (id: string | undefined, desc: boolean) =>
set((s) => ({
...s,
sortBy: typeof id === 'string' ? { id, desc } : id,
})),
};
}
@@ -77,7 +82,7 @@ export interface BasicTableSettings
export function createPersistedStore<T extends BasicTableSettings>(
storageKey: string,
initialSortBy: string | { id: string; desc: boolean } = 'name',
initialSortBy?: string | { id: string; desc: boolean },
create: (set: ZustandSetFunc<T>) => Omit<T, keyof BasicTableSettings> = () =>
({} as T)
) {
@@ -85,11 +90,8 @@ export function createPersistedStore<T extends BasicTableSettings>(
persist(
(set) =>
({
...sortableSettings(
set as ZustandSetFunc<SortableTableSettings>,
initialSortBy
),
...paginationSettings(set as ZustandSetFunc<PaginationTableSettings>),
...sortableSettings<T>(set, initialSortBy),
...paginationSettings<T>(set),
...create(set),
} as T),
{
@@ -98,18 +100,3 @@ export function createPersistedStore<T extends BasicTableSettings>(
)
);
}
/** this class is just a dummy class to get return type of createPersistedStore
* can be fixed after upgrade to ts 4.7+
* https://stackoverflow.com/a/64919133
*/
class Wrapper<T extends BasicTableSettings> {
// eslint-disable-next-line class-methods-use-this
wrapped() {
return createPersistedStore<T>('', '');
}
}
export type CreatePersistedStoreReturn<
T extends BasicTableSettings = BasicTableSettings
> = ReturnType<Wrapper<T>['wrapped']>;

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useStore } from 'zustand';
import { useSearchBarState } from './SearchBar';
import { BasicTableSettings, CreatePersistedStoreReturn } from './types';
import { BasicTableSettings, createPersistedStore } from './types';
export type TableState<TSettings extends BasicTableSettings> = TSettings & {
setSearch: (search: string) => void;
@@ -11,7 +11,10 @@ export type TableState<TSettings extends BasicTableSettings> = TSettings & {
export function useTableState<
TSettings extends BasicTableSettings = BasicTableSettings
>(store: CreatePersistedStoreReturn<TSettings>, storageKey: string) {
>(
store: ReturnType<typeof createPersistedStore<TSettings>>,
storageKey: string
) {
const settings = useStore(store);
const [search, setSearch] = useSearchBarState(storageKey);
@@ -23,21 +26,24 @@ export function useTableState<
}
export function useTableStateWithoutStorage(
defaultSortKey: string
defaultSortKey?: string
): BasicTableSettings & {
setSearch: (search: string) => void;
search: string;
} {
const [search, setSearch] = useState('');
const [pageSize, setPageSize] = useState(10);
const [sortBy, setSortBy] = useState({ id: defaultSortKey, desc: false });
const [sortBy, setSortBy] = useState(
defaultSortKey ? { id: defaultSortKey, desc: false } : undefined
);
return {
search,
setSearch,
pageSize,
setPageSize,
setSortBy: (id: string, desc: boolean) => setSortBy({ id, desc }),
setSortBy: (id: string | undefined, desc: boolean) =>
setSortBy(id ? { id, desc } : undefined),
sortBy,
};
}

View File

@@ -3,14 +3,14 @@ import { createColumnHelper } from '@tanstack/react-table';
import { isoDate } from '@/portainer/filters/filters';
import { createOwnershipColumn } from '@/react/docker/components/datatable-helpers/createOwnershipColumn';
import { buildNameColumn } from '@@/datatables/NameCell';
import { buildNameColumn } from '@@/datatables/buildNameColumn';
import { DockerConfig } from '../../types';
const columnHelper = createColumnHelper<DockerConfig>();
export const columns = [
buildNameColumn<DockerConfig>('Name', 'Id', 'docker.configs.config'),
buildNameColumn<DockerConfig>('Name', 'docker.configs.config'),
columnHelper.accessor('CreatedAt', {
header: 'Creation Date',
cell: ({ getValue }) => {

View File

@@ -9,7 +9,7 @@ import { Link } from '@@/Link';
import styles from './ContainerQuickActions.module.css';
interface QuickActionsState {
export interface QuickActionsState {
showQuickActionAttach: boolean;
showQuickActionExec: boolean;
showQuickActionInspect: boolean;
@@ -17,31 +17,25 @@ interface QuickActionsState {
showQuickActionStats: boolean;
}
interface Props {
taskId?: string;
containerId?: string;
nodeName: string;
state: QuickActionsState;
status: ContainerStatus;
}
export function ContainerQuickActions({
taskId,
status,
containerId,
nodeName,
state,
status,
}: Props) {
if (taskId) {
return <TaskQuickActions taskId={taskId} state={state} />;
}
const isActive = [
ContainerStatus.Starting,
ContainerStatus.Running,
ContainerStatus.Healthy,
ContainerStatus.Unhealthy,
].includes(status);
}: {
containerId: string;
nodeName: string;
status: ContainerStatus;
state: QuickActionsState;
}) {
const isActive =
!!status &&
[
ContainerStatus.Starting,
ContainerStatus.Running,
ContainerStatus.Healthy,
ContainerStatus.Unhealthy,
].includes(status);
return (
<div className={clsx('space-x-1', styles.root)}>
@@ -107,34 +101,3 @@ export function ContainerQuickActions({
</div>
);
}
interface TaskProps {
taskId: string;
state: QuickActionsState;
}
function TaskQuickActions({ taskId, state }: TaskProps) {
return (
<div className={clsx('space-x-1', styles.root)}>
{state.showQuickActionLogs && (
<Authorized authorizations="DockerTaskLogs">
<Link
to="docker.tasks.task.logs"
params={{ id: taskId }}
title="Logs"
>
<Icon icon={FileText} className="space-right" />
</Link>
</Authorized>
)}
{state.showQuickActionInspect && (
<Authorized authorizations="DockerTaskInspect">
<Link to="docker.tasks.task" params={{ id: taskId }} title="Inspect">
<Icon icon={Info} className="space-right" />
</Link>
</Authorized>
)}
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import { buildUrl as buildProxyUrl } from '../build-url';
export function buildUrl(
environmentId: EnvironmentId,
action?: string,
subAction = ''
) {
return buildProxyUrl(
environmentId,
'nodes',
subAction ? `${action}/${subAction}` : action
);
}

View File

@@ -0,0 +1,8 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys as proxyQueryKeys } from '../query-keys';
export const queryKeys = {
base: (environmentId: EnvironmentId) =>
[...proxyQueryKeys.base(environmentId), 'nodes'] as const,
};

View File

@@ -0,0 +1,21 @@
import { Node } from 'docker-types/generated/1.41';
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export function useNodes(environmentId: EnvironmentId) {
return useQuery(queryKeys.base(environmentId), () => getNodes(environmentId));
}
async function getNodes(environmentId: EnvironmentId) {
try {
const { data } = await axios.get<Array<Node>>(buildUrl(environmentId));
return data;
} catch (error) {
throw parseAxiosError(error, 'Unable to retrieve nodes');
}
}

View File

@@ -0,0 +1,6 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
export const queryKeys = {
base: (environmentId: EnvironmentId) =>
[environmentId, 'docker', 'proxy'] as const,
};

View File

@@ -0,0 +1,21 @@
import { NestedDatatable } from '@@/datatables/NestedDatatable';
import { columns } from './columns';
import { DecoratedTask } from './types';
export function TasksDatatable({
dataset,
search,
}: {
dataset: DecoratedTask[];
search?: string;
}) {
return (
<NestedDatatable
columns={columns}
dataset={dataset}
search={search}
emptyContentLabel="No task matching filter."
/>
);
}

View File

@@ -0,0 +1,45 @@
import { CellContext } from '@tanstack/react-table';
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
import { QuickActionsState } from '@/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions';
import { TaskTableQuickActions } from '@/react/docker/services/common/TaskTableQuickActions';
import { DecoratedTask } from '../types';
import { columnHelper } from './helper';
export const actions = columnHelper.display({
header: 'Actions',
cell: Cell,
});
function Cell({
row: { original: item },
}: CellContext<DecoratedTask, unknown>) {
const environmentQuery = useCurrentEnvironment();
if (!environmentQuery.data) {
return null;
}
const state: QuickActionsState = {
showQuickActionAttach: true,
showQuickActionExec: true,
showQuickActionInspect: true,
showQuickActionLogs: true,
showQuickActionStats: true,
};
const isAgent = isAgentEnvironment(environmentQuery.data.Type);
return isAgent && item.Container ? (
<ContainerQuickActions
containerId={item.Container.Id}
nodeName={item.Container.NodeName}
status={item.Container.Status}
state={state}
/>
) : (
<TaskTableQuickActions taskId={item.Id} />
);
}

View File

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

View File

@@ -0,0 +1,19 @@
import { isoDate } from '@/portainer/filters/filters';
import { actions } from './actions';
import { columnHelper } from './helper';
import { node } from './node';
import { status } from './status';
import { task } from './task';
export const columns = [
status,
task,
actions,
columnHelper.accessor((item) => item.Slot || '-', { header: 'Slot' }),
node,
columnHelper.accessor('Updated', {
header: 'Last Update',
cell: ({ getValue }) => isoDate(getValue()),
}),
];

View File

@@ -0,0 +1,32 @@
import { Node } from 'docker-types/generated/1.41';
import { CellContext } from '@tanstack/react-table';
import { useNodes } from '@/react/docker/proxy/queries/nodes/useNodes';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { DecoratedTask } from '../types';
import { columnHelper } from './helper';
export const node = columnHelper.accessor('NodeId', {
header: 'Node',
cell: Cell,
});
function Cell({ getValue }: CellContext<DecoratedTask, string>) {
const environmentId = useEnvironmentId();
const nodesQuery = useNodes(environmentId);
const nodes = nodesQuery.data || [];
return getNodeName(getValue(), nodes);
}
function getNodeName(nodeId: string, nodes: Array<Node>) {
const node = nodes.find((node) => node.ID === nodeId);
if (node?.Description?.Hostname) {
return node.Description.Hostname;
}
return '';
}

View File

@@ -0,0 +1,27 @@
import clsx from 'clsx';
import { taskStatusBadge } from '@/docker/filters/utils';
import { multiple } from '@@/datatables/filter-types';
import { filterHOC } from '@@/datatables/Filter';
import { columnHelper } from './helper';
export const status = columnHelper.accessor((item) => item.Status?.State, {
header: 'Status',
enableColumnFilter: true,
filterFn: multiple,
meta: {
filter: filterHOC('Filter by state'),
width: 100,
},
cell({ getValue }) {
const value = getValue();
return (
<span className={clsx('label', `label-${taskStatusBadge(value)}`)}>
{value}
</span>
);
},
});

View File

@@ -0,0 +1,47 @@
import { CellContext } from '@tanstack/react-table';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { isAgentEnvironment } from '@/react/portainer/environments/utils';
import { Link } from '@@/Link';
import { DecoratedTask } from '../types';
import { columnHelper } from './helper';
export const task = columnHelper.accessor('Id', {
header: 'Task',
cell: Cell,
});
function Cell({
getValue,
row: { original: item },
}: CellContext<DecoratedTask, string>) {
const environmentQuery = useCurrentEnvironment();
if (!environmentQuery.data) {
return null;
}
const value = getValue();
const isAgent = isAgentEnvironment(environmentQuery.data.Type);
return isAgent && item.Container ? (
<Link
to="docker.containers.container"
params={{ id: item.Container.Id, nodeName: item.Container.NodeName }}
className="monospaced"
>
{value}
</Link>
) : (
<Link
to="docker.tasks.task"
params={{ id: item.Id }}
className="monospaced"
>
{value}
</Link>
);
}

View File

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

View File

@@ -0,0 +1,6 @@
import { TaskViewModel } from '@/docker/models/task';
import { DockerContainer } from '@/react/docker/containers/types';
export type DecoratedTask = TaskViewModel & {
Container?: DockerContainer;
};

View File

@@ -0,0 +1,46 @@
import { FileText, Info } from 'lucide-react';
import { Authorized } from '@/react/hooks/useUser';
import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
interface State {
showQuickActionInspect: boolean;
showQuickActionLogs: boolean;
}
export function TaskTableQuickActions({
taskId,
state = {
showQuickActionInspect: true,
showQuickActionLogs: true,
},
}: {
taskId: string;
state?: State;
}) {
return (
<div className="inline-flex space-x-1">
{state.showQuickActionLogs && (
<Authorized authorizations="DockerTaskLogs">
<Link
to="docker.tasks.task.logs"
params={{ id: taskId }}
title="Logs"
>
<Icon icon={FileText} className="space-right" />
</Link>
</Authorized>
)}
{state.showQuickActionInspect && (
<Authorized authorizations="DockerTaskInspect">
<Link to="docker.tasks.task" params={{ id: taskId }} title="Inspect">
<Icon icon={Info} className="space-right" />
</Link>
</Authorized>
)}
</div>
);
}

View File

@@ -56,8 +56,8 @@ export function EdgeGroupAssociationTable({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: tableState.sortBy.id as 'Group' | 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc',
sort: tableState.sortBy?.id as 'Group' | 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
...query,
});
const groupsQuery = useGroups({

View File

@@ -9,7 +9,7 @@ import { useEnvironments } from './useEnvironments';
const storageKey = 'edge-devices-waiting-room';
const settingsStore = createPersistedStore(storageKey);
const settingsStore = createPersistedStore(storageKey, 'name');
export function Datatable() {
const tableState = useTableState(settingsStore, storageKey);

View File

@@ -44,8 +44,8 @@ export function EnvironmentsDatatable() {
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: tableState.sortBy.id as 'Group' | 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc',
sort: tableState.sortBy?.id as 'Group' | 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
edgeStackId: stackId,
edgeStackStatus: statusFilter,
});

View File

@@ -4,7 +4,7 @@ import _ from 'lodash';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { buildNameColumn } from '@@/datatables/NameCell';
import { buildNameColumn } from '@@/datatables/buildNameColumn';
import { Link } from '@@/Link';
import { StatusType } from '../../types';
@@ -16,12 +16,7 @@ import { DeploymentCounter } from './DeploymentCounter';
const columnHelper = createColumnHelper<DecoratedEdgeStack>();
export const columns = _.compact([
buildNameColumn<DecoratedEdgeStack>(
'Name',
'Id',
'edge.stacks.edit',
'stackId'
),
buildNameColumn<DecoratedEdgeStack>('Name', 'edge.stacks.edit', 'stackId'),
columnHelper.accessor(
(item) => item.aggregatedStatus[StatusType.Acknowledged] || 0,
{

View File

@@ -15,7 +15,7 @@ import { IngressControllerClassMap } from '../types';
import { columns } from './columns';
const storageKey = 'ingressClasses';
const settingsStore = createPersistedStore(storageKey);
const settingsStore = createPersistedStore(storageKey, 'name');
interface Props {
onChangeControllers: (

View File

@@ -37,8 +37,10 @@ export function EnvironmentsDatatable({
excludeSnapshots: true,
page: page + 1,
pageLimit: tableState.pageSize,
sort: isSortType(tableState.sortBy.id) ? tableState.sortBy.id : undefined,
order: tableState.sortBy.desc ? 'desc' : 'asc',
sort: isSortType(tableState.sortBy?.id)
? tableState.sortBy?.id
: undefined,
order: tableState.sortBy?.desc ? 'desc' : 'asc',
},
{ enabled: groupsQuery.isSuccess, refetchInterval: 30 * 1000 }
);

View File

@@ -38,8 +38,8 @@ export function GroupAssociationTable({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: tableState.sortBy.id as 'Name',
order: tableState.sortBy.desc ? 'desc' : 'asc',
sort: tableState.sortBy?.id as 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
...query,
});

View File

@@ -14,7 +14,7 @@ export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
export const SortOptions = ['Name', 'Group', 'Status'] as const;
export type SortType = (typeof SortOptions)[number];
export function isSortType(value: string): value is SortType {
export function isSortType(value?: string): value is SortType {
return SortOptions.includes(value as SortType);
}

View File

@@ -1,4 +1,4 @@
import { buildNameColumn } from '@@/datatables/NameCell';
import { buildNameColumn } from '@@/datatables/buildNameColumn';
import { EdgeUpdateListItemResponse } from '../../queries/list';
@@ -9,7 +9,7 @@ import { scheduledTime } from './scheduled-time';
import { scheduleType } from './type';
export const columns = [
buildNameColumn<EdgeUpdateListItemResponse>('name', 'id', '.item'),
buildNameColumn<EdgeUpdateListItemResponse>('name', '.item'),
scheduledTime,
groups,
scheduleType,

View File

@@ -1,10 +1,10 @@
import { Profile } from '@/portainer/hostmanagement/fdo/model';
import { buildNameColumn } from '@@/datatables/NameCell';
import { buildNameColumn } from '@@/datatables/buildNameColumn';
import { created } from './created';
export const columns = [
buildNameColumn<Profile>('name', 'id', 'portainer.endpoints.profile.edit'),
buildNameColumn<Profile>('name', 'portainer.endpoints.profile.edit'),
created,
];

View File

@@ -33,7 +33,9 @@ export function TeamMembersList({ users, roles, disabled, teamId }: Props) {
const [search, setSearch] = useState('');
const [pageSize, setPageSize] = useState(10);
const [sortBy, setSortBy] = useState({ id: 'name', desc: false });
const [sortBy, setSortBy] = useState<
{ id: string; desc: boolean } | undefined
>({ id: 'name', desc: false });
const { isAdmin } = useUser();
@@ -79,8 +81,8 @@ export function TeamMembersList({ users, roles, disabled, teamId }: Props) {
</RowProvider>
);
function handleSetSort(colId: string, desc: boolean) {
setSortBy({ id: colId, desc });
function handleSetSort(colId: string | undefined, desc: boolean) {
setSortBy(colId ? { id: colId, desc } : undefined);
}
function handleRemoveMembers(userIds: UserId[]) {

View File

@@ -25,7 +25,9 @@ export function UsersList({ users, disabled, teamId }: Props) {
const [search, setSearch] = useState('');
const [pageSize, setPageSize] = useState(10);
const addMemberMutation = useAddMemberMutation(teamId);
const [sortBy, setSortBy] = useState({ id: 'name', desc: false });
const [sortBy, setSortBy] = useState<
{ id: string; desc: boolean } | undefined
>({ id: 'name', desc: false });
const { isAdmin } = useUser();
@@ -62,8 +64,8 @@ export function UsersList({ users, disabled, teamId }: Props) {
</RowProvider>
);
function handleSetSort(colId: string, desc: boolean) {
setSortBy({ id: colId, desc });
function handleSetSort(colId: string | undefined, desc: boolean) {
setSortBy(colId ? { id: colId, desc } : undefined);
}
function handleAddAllMembers(userIds: UserId[]) {

View File

@@ -10,14 +10,14 @@ import { deleteTeam } from '@/react/portainer/users/teams/teams.service';
import { confirmDelete } from '@@/modals/confirm';
import { Datatable } from '@@/datatables';
import { Button } from '@@/buttons';
import { buildNameColumn } from '@@/datatables/NameCell';
import { buildNameColumn } from '@@/datatables/buildNameColumn';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
const storageKey = 'teams';
const columns: ColumnDef<Team>[] = [
buildNameColumn<Team>('Name', 'Id', 'portainer.teams.team'),
buildNameColumn<Team>('Name', 'portainer.teams.team'),
];
interface Props {
@@ -25,7 +25,7 @@ interface Props {
isAdmin: boolean;
}
const settingsStore = createPersistedStore(storageKey);
const settingsStore = createPersistedStore(storageKey, 'name');
export function TeamsDatatable({ teams, isAdmin }: Props) {
const { handleRemove } = useRemoveMutation();

View File

@@ -85,6 +85,7 @@
"codemirror": "^6.0.1",
"core-js": "^3.19.3",
"date-fns": "^2.29.3",
"docker-types": "^1.42.2",
"fast-json-patch": "^3.1.1",
"file-saver": "^2.0.5",
"filesize": "~3.3.0",

View File

@@ -15,7 +15,7 @@
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"
"@apidevtools/json-schema-ref-parser@^9.0.6":
"@apidevtools/json-schema-ref-parser@9.0.9", "@apidevtools/json-schema-ref-parser@^9.0.6":
version "9.0.9"
resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz#d720f9256e3609621280584f2b47ae165359268b"
integrity sha512-GBD2Le9w2+lVFoc4vswGI/TjkNIZSVp7+9xPf+X3uidBfWnAeUWmquteSyt0+VCrhNMWj/FTABISQrD3Z/YA+w==
@@ -6807,6 +6807,13 @@ buffer@^6.0.3:
base64-js "^1.3.1"
ieee754 "^1.2.1"
busboy@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
dependencies:
streamsearch "^1.1.0"
bytes@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
@@ -6879,7 +6886,7 @@ camelcase@^5.0.0, camelcase@^5.3.1:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
camelcase@^6.2.0:
camelcase@^6.2.0, camelcase@^6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
@@ -8099,6 +8106,14 @@ dns-packet@^5.2.2:
dependencies:
"@leichtgewicht/ip-codec" "^2.0.1"
docker-types@^1.42.2:
version "1.42.2"
resolved "https://registry.yarnpkg.com/docker-types/-/docker-types-1.42.2.tgz#40a3626abf99030abe306966d51b3fdae9c77408"
integrity sha512-Il8PAGTZpgRu8vMg+MnRTAD/FdEsTN2LYEFLHhhmiAWdGYkJHxDHWYSeBIIQMR6pJ/biHaF9qsTnYsJHX3OPTw==
dependencies:
openapi-typescript "5.4.1"
openapi-typescript-codegen "^0.24.0"
doctrine@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
@@ -9383,7 +9398,7 @@ fs-constants@^1.0.0:
resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
fs-extra@11.1.1, fs-extra@^11.1.0:
fs-extra@11.1.1, fs-extra@^11.1.0, fs-extra@^11.1.1:
version "11.1.1"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.1.1.tgz#da69f7c39f3b002378b0954bb6ae7efdc0876e2d"
integrity sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==
@@ -9653,6 +9668,11 @@ globalthis@^1.0.3:
dependencies:
define-properties "^1.1.3"
globalyzer@0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/globalyzer/-/globalyzer-0.1.0.tgz#cb76da79555669a1519d5a8edf093afaa0bf1465"
integrity sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==
globby@^11.0.1:
version "11.0.4"
resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5"
@@ -9699,6 +9719,11 @@ globby@^6.1.0:
pify "^2.0.0"
pinkie-promise "^2.0.0"
globrex@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098"
integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==
gopd@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c"
@@ -11313,6 +11338,13 @@ json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1:
resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
json-schema-ref-parser@^9.0.9:
version "9.0.9"
resolved "https://registry.yarnpkg.com/json-schema-ref-parser/-/json-schema-ref-parser-9.0.9.tgz#66ea538e7450b12af342fa3d5b8458bc1e1e013f"
integrity sha512-qcP2lmGy+JUoQJ4DOQeLaZDqH9qSkeGCK3suKWxJXS82dg728Mn3j97azDMaOUmJAN4uCq91LdPx4K7E8F1a7Q==
dependencies:
"@apidevtools/json-schema-ref-parser" "9.0.9"
json-schema-traverse@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
@@ -11897,6 +11929,11 @@ mime@^2.0.3:
resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
mime@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7"
integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==
mimic-fn@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
@@ -12543,6 +12580,29 @@ open@^8.4.0:
is-docker "^2.1.1"
is-wsl "^2.2.0"
openapi-typescript-codegen@^0.24.0:
version "0.24.0"
resolved "https://registry.yarnpkg.com/openapi-typescript-codegen/-/openapi-typescript-codegen-0.24.0.tgz#b3e6ade5bae75cd47868e5e3e4dc3bcf899cadab"
integrity sha512-rSt8t1XbMWhv6Db7GUI24NNli7FU5kzHLxcE8BpzgGWRdWyWt9IB2YoLyPahxNrVA7yOaVgnXPkrcTDRMQtJYg==
dependencies:
camelcase "^6.3.0"
commander "^10.0.0"
fs-extra "^11.1.1"
handlebars "^4.7.7"
json-schema-ref-parser "^9.0.9"
openapi-typescript@5.4.1:
version "5.4.1"
resolved "https://registry.yarnpkg.com/openapi-typescript/-/openapi-typescript-5.4.1.tgz#38b4b45244acc1361f3c444537833a9e9cb03bf6"
integrity sha512-AGB2QiZPz4rE7zIwV3dRHtoUC/CWHhUjuzGXvtmMQN2AFV8xCTLKcZUHLcdPQmt/83i22nRE7+TxXOXkK+gf4Q==
dependencies:
js-yaml "^4.1.0"
mime "^3.0.0"
prettier "^2.6.2"
tiny-glob "^0.2.9"
undici "^5.4.0"
yargs-parser "^21.0.1"
opener@^1.5.2:
version "1.5.2"
resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598"
@@ -13326,7 +13386,7 @@ prettier-plugin-tailwindcss@^0.2.6:
resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.2.8.tgz#e9c0356680331f909a86fefe8fc2b247c21e23a2"
integrity sha512-KgPcEnJeIijlMjsA6WwYgRs5rh3/q76oInqtMXBA/EMcamrcYJpyhtRhyX1ayT9hnHlHTuO8sIifHF10WuSDKg==
prettier@^2.8.0, prettier@^2.8.8:
prettier@^2.6.2, prettier@^2.8.0, prettier@^2.8.8:
version "2.8.8"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da"
integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==
@@ -14870,6 +14930,11 @@ stream-shift@^1.0.0:
resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
streamsearch@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
strict-event-emitter@^0.2.4, strict-event-emitter@^0.2.6:
version "0.2.8"
resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.2.8.tgz#b4e768927c67273c14c13d20e19d5e6c934b47ca"
@@ -15344,6 +15409,14 @@ thunky@^1.0.2:
resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==
tiny-glob@^0.2.9:
version "0.2.9"
resolved "https://registry.yarnpkg.com/tiny-glob/-/tiny-glob-0.2.9.tgz#2212d441ac17928033b110f8b3640683129d31e2"
integrity sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==
dependencies:
globalyzer "0.1.0"
globrex "^0.1.2"
tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
@@ -15634,6 +15707,13 @@ unc-path-regex@^0.1.2:
resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa"
integrity sha1-5z3T17DXxe2G+6xrCufYxqadUPo=
undici@^5.4.0:
version "5.23.0"
resolved "https://registry.yarnpkg.com/undici/-/undici-5.23.0.tgz#e7bdb0ed42cebe7b7aca87ced53e6eaafb8f8ca0"
integrity sha512-1D7w+fvRsqlQ9GscLBwcAJinqcZGHUKjbOmXdlE/v8BvEGXjeWAax+341q44EuTcHXXnfyKNbKRq4Lg7OzhMmg==
dependencies:
busboy "^1.6.0"
unfetch@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.2.0.tgz#7e21b0ef7d363d8d9af0fb929a5555f6ef97a3be"
@@ -16451,7 +16531,7 @@ yargs-parser@^20.2.2, yargs-parser@^20.2.9:
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"
integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
yargs-parser@^21.0.0, yargs-parser@^21.1.1:
yargs-parser@^21.0.0, yargs-parser@^21.0.1, yargs-parser@^21.1.1:
version "21.1.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==