diff --git a/.eslintrc.yml b/.eslintrc.yml
index 5ccead36c..cc4e73a73 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -88,6 +88,8 @@ overrides:
'jsx-a11y/label-has-associated-control': ['error', { 'assert': 'either' }]
'react/function-component-definition': ['error', { 'namedComponents': 'function-declaration' }]
'react/jsx-no-bind': off
+ 'no-await-in-loop': 'off'
+ 'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }]
- files:
- app/**/*.test.*
extends:
diff --git a/app/assets/css/app.css b/app/assets/css/app.css
index b043f74ba..7a8122f14 100644
--- a/app/assets/css/app.css
+++ b/app/assets/css/app.css
@@ -82,6 +82,13 @@ input[type='checkbox'] {
margin-top: -1px;
}
+.md-checkbox input[type='checkbox']:indeterminate + label:before {
+ content: '-';
+ align-content: center;
+ line-height: 16px;
+ text-align: center;
+}
+
a[ng-click] {
cursor: pointer;
}
@@ -885,3 +892,7 @@ json-tree .branch-preview {
word-break: break-all;
white-space: normal;
}
+
+.space-x-1 {
+ margin-left: 0.25rem;
+}
diff --git a/app/assets/css/index.js b/app/assets/css/index.js
index c48ded8c2..9cd17ecec 100644
--- a/app/assets/css/index.js
+++ b/app/assets/css/index.js
@@ -13,6 +13,7 @@ import 'angular-loading-bar/build/loading-bar.css';
import 'angular-moment-picker/dist/angular-moment-picker.min.css';
import 'angular-multiselect/isteven-multi-select.css';
import 'spinkit/spinkit.min.css';
+import '@reach/menu-button/styles.css';
import './rdash.css';
import './app.css';
diff --git a/app/docker/__module.js b/app/docker/__module.js
index a34a8e4cd..828862870 100644
--- a/app/docker/__module.js
+++ b/app/docker/__module.js
@@ -1,4 +1,8 @@
-angular.module('portainer.docker', ['portainer.app']).config([
+import angular from 'angular';
+
+import containersModule from './containers';
+
+angular.module('portainer.docker', ['portainer.app', containersModule]).config([
'$stateRegistryProvider',
function ($stateRegistryProvider) {
'use strict';
diff --git a/app/docker/components/container-quick-actions/ContainerQuickActions.module.css b/app/docker/components/container-quick-actions/ContainerQuickActions.module.css
new file mode 100644
index 000000000..dfc0a60d1
--- /dev/null
+++ b/app/docker/components/container-quick-actions/ContainerQuickActions.module.css
@@ -0,0 +1,3 @@
+.root {
+ display: inline-flex;
+}
diff --git a/app/docker/components/container-quick-actions/ContainerQuickActions.tsx b/app/docker/components/container-quick-actions/ContainerQuickActions.tsx
new file mode 100644
index 000000000..41e411bef
--- /dev/null
+++ b/app/docker/components/container-quick-actions/ContainerQuickActions.tsx
@@ -0,0 +1,140 @@
+import clsx from 'clsx';
+
+import { Authorized } from '@/portainer/hooks/useUser';
+import { Link } from '@/portainer/components/Link';
+import { react2angular } from '@/react-tools/react2angular';
+import { DockerContainerStatus } from '@/docker/containers/types';
+
+import styles from './ContainerQuickActions.module.css';
+
+interface QuickActionsState {
+ showQuickActionAttach: boolean;
+ showQuickActionExec: boolean;
+ showQuickActionInspect: boolean;
+ showQuickActionLogs: boolean;
+ showQuickActionStats: boolean;
+}
+
+interface Props {
+ taskId?: string;
+ containerId?: string;
+ nodeName: string;
+ state: QuickActionsState;
+ status: DockerContainerStatus;
+}
+
+export function ContainerQuickActions({
+ taskId,
+ containerId,
+ nodeName,
+ state,
+ status,
+}: Props) {
+ if (taskId) {
+ return ;
+ }
+
+ const isActive = ['starting', 'running', 'healthy', 'unhealthy'].includes(
+ status
+ );
+
+ return (
+
+ {state.showQuickActionLogs && (
+
+
+
+
+
+ )}
+
+ {state.showQuickActionInspect && (
+
+
+
+
+
+ )}
+
+ {state.showQuickActionStats && isActive && (
+
+
+
+
+
+ )}
+
+ {state.showQuickActionExec && isActive && (
+
+
+
+
+
+ )}
+
+ {state.showQuickActionAttach && isActive && (
+
+
+
+
+
+ )}
+
+ );
+}
+
+interface TaskProps {
+ taskId: string;
+ state: QuickActionsState;
+}
+
+function TaskQuickActions({ taskId, state }: TaskProps) {
+ return (
+
+ {state.showQuickActionLogs && (
+
+
+
+
+
+ )}
+
+ {state.showQuickActionInspect && (
+
+
+
+
+
+ )}
+
+ );
+}
+
+export const ContainerQuickActionsAngular = react2angular(
+ ContainerQuickActions,
+ ['taskId', 'containerId', 'nodeName', 'state', 'status']
+);
diff --git a/app/docker/components/container-quick-actions/containerQuickActions.html b/app/docker/components/container-quick-actions/containerQuickActions.html
deleted file mode 100644
index d4bbc55d1..000000000
--- a/app/docker/components/container-quick-actions/containerQuickActions.html
+++ /dev/null
@@ -1,65 +0,0 @@
-
diff --git a/app/docker/components/container-quick-actions/containerQuickActions.js b/app/docker/components/container-quick-actions/containerQuickActions.js
deleted file mode 100644
index 6f8631df7..000000000
--- a/app/docker/components/container-quick-actions/containerQuickActions.js
+++ /dev/null
@@ -1,10 +0,0 @@
-angular.module('portainer.docker').component('containerQuickActions', {
- templateUrl: './containerQuickActions.html',
- bindings: {
- containerId: '<',
- nodeName: '<',
- status: '<',
- state: '<',
- taskId: '<',
- },
-});
diff --git a/app/docker/components/container-quick-actions/index.ts b/app/docker/components/container-quick-actions/index.ts
new file mode 100644
index 000000000..2be542a63
--- /dev/null
+++ b/app/docker/components/container-quick-actions/index.ts
@@ -0,0 +1,7 @@
+import angular from 'angular';
+
+import { ContainerQuickActionsAngular } from './ContainerQuickActions';
+
+angular
+ .module('portainer.docker')
+ .component('containerQuickActions', ContainerQuickActionsAngular);
diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html
deleted file mode 100644
index 9d9f8d9f8..000000000
--- a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.html
+++ /dev/null
@@ -1,73 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js
deleted file mode 100644
index b6f83f273..000000000
--- a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActions.js
+++ /dev/null
@@ -1,12 +0,0 @@
-angular.module('portainer.docker').component('containersDatatableActions', {
- templateUrl: './containersDatatableActions.html',
- controller: 'ContainersDatatableActionsController',
- bindings: {
- selectedItems: '=',
- selectedItemCount: '=',
- noStoppedItemsSelected: '=',
- noRunningItemsSelected: '=',
- noPausedItemsSelected: '=',
- showAddAction: '<',
- },
-});
diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js
deleted file mode 100644
index 432df50f8..000000000
--- a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js
+++ /dev/null
@@ -1,112 +0,0 @@
-angular.module('portainer.docker').controller('ContainersDatatableActionsController', [
- '$state',
- 'ContainerService',
- 'ModalService',
- 'Notifications',
- 'HttpRequestHelper',
- function ($state, ContainerService, ModalService, Notifications, HttpRequestHelper) {
- this.startAction = function (selectedItems) {
- var successMessage = 'Container successfully started';
- var errorMessage = 'Unable to start container';
- executeActionOnContainerList(selectedItems, ContainerService.startContainer, successMessage, errorMessage);
- };
-
- this.stopAction = function (selectedItems) {
- var successMessage = 'Container successfully stopped';
- var errorMessage = 'Unable to stop container';
- executeActionOnContainerList(selectedItems, ContainerService.stopContainer, successMessage, errorMessage);
- };
-
- this.restartAction = function (selectedItems) {
- var successMessage = 'Container successfully restarted';
- var errorMessage = 'Unable to restart container';
- executeActionOnContainerList(selectedItems, ContainerService.restartContainer, successMessage, errorMessage);
- };
-
- this.killAction = function (selectedItems) {
- var successMessage = 'Container successfully killed';
- var errorMessage = 'Unable to kill container';
- executeActionOnContainerList(selectedItems, ContainerService.killContainer, successMessage, errorMessage);
- };
-
- this.pauseAction = function (selectedItems) {
- var successMessage = 'Container successfully paused';
- var errorMessage = 'Unable to pause container';
- executeActionOnContainerList(selectedItems, ContainerService.pauseContainer, successMessage, errorMessage);
- };
-
- this.resumeAction = function (selectedItems) {
- var successMessage = 'Container successfully resumed';
- var errorMessage = 'Unable to resume container';
- executeActionOnContainerList(selectedItems, ContainerService.resumeContainer, successMessage, errorMessage);
- };
-
- this.removeAction = function (selectedItems) {
- var isOneContainerRunning = false;
- for (var i = 0; i < selectedItems.length; i++) {
- var container = selectedItems[i];
- if (container.State === 'running') {
- isOneContainerRunning = true;
- break;
- }
- }
-
- var title = 'You are about to remove one or more container.';
- if (isOneContainerRunning) {
- title = 'You are about to remove one or more running container.';
- }
-
- ModalService.confirmContainerDeletion(title, function (result) {
- if (!result) {
- return;
- }
- var cleanVolumes = false;
- if (result[0]) {
- cleanVolumes = true;
- }
- removeSelectedContainers(selectedItems, cleanVolumes);
- });
- };
-
- function executeActionOnContainerList(containers, action, successMessage, errorMessage) {
- var actionCount = containers.length;
- angular.forEach(containers, function (container) {
- HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName);
- action(container.Id)
- .then(function success() {
- Notifications.success(successMessage, container.Names[0]);
- })
- .catch(function error(err) {
- errorMessage = errorMessage + ':' + container.Names[0];
- Notifications.error('Failure', err, errorMessage);
- })
- .finally(function final() {
- --actionCount;
- if (actionCount === 0) {
- $state.reload();
- }
- });
- });
- }
-
- function removeSelectedContainers(containers, cleanVolumes) {
- var actionCount = containers.length;
- angular.forEach(containers, function (container) {
- HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName);
- ContainerService.remove(container, cleanVolumes)
- .then(function success() {
- Notifications.success('Container successfully removed', container.Names[0]);
- })
- .catch(function error(err) {
- Notifications.error('Failure', err, 'Unable to remove container');
- })
- .finally(function final() {
- --actionCount;
- if (actionCount === 0) {
- $state.reload();
- }
- });
- });
- }
- },
-]);
diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html
deleted file mode 100644
index e48177a49..000000000
--- a/app/docker/components/datatables/containers-datatable/containersDatatable.html
+++ /dev/null
@@ -1,312 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.js b/app/docker/components/datatables/containers-datatable/containersDatatable.js
deleted file mode 100644
index e93050f81..000000000
--- a/app/docker/components/datatables/containers-datatable/containersDatatable.js
+++ /dev/null
@@ -1,18 +0,0 @@
-angular.module('portainer.docker').component('containersDatatable', {
- templateUrl: './containersDatatable.html',
- controller: 'ContainersDatatableController',
- bindings: {
- titleText: '@',
- titleIcon: '@',
- dataset: '<',
- tableKey: '@',
- orderBy: '@',
- reverseOrder: '<',
- showHostColumn: '<',
- showAddAction: '<',
- offlineMode: '<',
- refreshCallback: '<',
- notAutoFocus: '<',
- endpointPublicUrl: '<',
- },
-});
diff --git a/app/docker/components/datatables/containers-datatable/containersDatatableController.js b/app/docker/components/datatables/containers-datatable/containersDatatableController.js
deleted file mode 100644
index 1178078de..000000000
--- a/app/docker/components/datatables/containers-datatable/containersDatatableController.js
+++ /dev/null
@@ -1,210 +0,0 @@
-import _ from 'lodash-es';
-
-angular.module('portainer.docker').controller('ContainersDatatableController', [
- '$scope',
- '$controller',
- 'DatatableService',
- function ($scope, $controller, DatatableService) {
- angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
-
- var ctrl = this;
-
- this.state = Object.assign(this.state, {
- noStoppedItemsSelected: true,
- noRunningItemsSelected: true,
- noPausedItemsSelected: true,
- });
-
- this.settings = Object.assign(this.settings, {
- truncateContainerName: true,
- containerNameTruncateSize: 32,
- showQuickActionStats: true,
- showQuickActionLogs: true,
- showQuickActionExec: true,
- showQuickActionInspect: true,
- showQuickActionAttach: false,
- });
-
- this.filters = {
- state: {
- open: false,
- enabled: false,
- values: [],
- },
- };
-
- this.columnVisibility = {
- columns: {
- state: {
- label: 'State',
- display: true,
- },
- actions: {
- label: 'Quick Actions',
- display: true,
- },
- stack: {
- label: 'Stack',
- display: true,
- },
- image: {
- label: 'Image',
- display: true,
- },
- created: {
- label: 'Created',
- display: true,
- },
- ip: {
- label: 'IP Address',
- display: true,
- },
- host: {
- label: 'Host',
- display: true,
- },
- ports: {
- label: 'Published Ports',
- display: true,
- },
- ownership: {
- label: 'Ownership',
- display: true,
- },
- },
- };
-
- this.allowSelection = function (item) {
- return !item.IsPortainer;
- };
-
- this.onColumnVisibilityChange = onColumnVisibilityChange.bind(this);
- function onColumnVisibilityChange(columns) {
- this.columnVisibility.columns = columns;
- DatatableService.setColumnVisibilitySettings(this.tableKey, this.columnVisibility);
- }
-
- this.onSelectionChanged = function () {
- this.updateSelectionState();
- };
-
- this.updateSelectionState = function () {
- this.state.noStoppedItemsSelected = true;
- this.state.noRunningItemsSelected = true;
- this.state.noPausedItemsSelected = true;
-
- for (var i = 0; i < this.dataset.length; i++) {
- var item = this.dataset[i];
- if (item.Checked) {
- this.updateSelectionStateBasedOnItemStatus(item);
- }
- }
- };
-
- this.updateSelectionStateBasedOnItemStatus = function (item) {
- if (item.Status === 'paused') {
- this.state.noPausedItemsSelected = false;
- } else if (['stopped', 'created'].indexOf(item.Status) !== -1) {
- this.state.noStoppedItemsSelected = false;
- } else if (['running', 'healthy', 'unhealthy', 'starting'].indexOf(item.Status) !== -1) {
- this.state.noRunningItemsSelected = false;
- }
- };
-
- this.applyFilters = function (value) {
- var container = value;
- var filters = ctrl.filters;
- for (var i = 0; i < filters.state.values.length; i++) {
- var filter = filters.state.values[i];
- if (container.Status === 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.onSettingsContainerNameTruncateChange = function () {
- if (this.settings.truncateContainerName) {
- this.settings.containerNameTruncateSize = 32;
- } else {
- this.settings.containerNameTruncateSize = 256;
- }
- DatatableService.setDataTableSettings(this.tableKey, this.settings);
- };
-
- this.onSettingsQuickActionChange = function () {
- DatatableService.setDataTableSettings(this.tableKey, this.settings);
- };
-
- this.prepareTableFromDataset = function () {
- var availableStateFilters = [];
- for (var i = 0; i < this.dataset.length; i++) {
- var item = this.dataset[i];
- availableStateFilters.push({ label: item.Status, display: true });
- }
- this.filters.state.values = _.uniqBy(availableStateFilters, 'label');
- };
-
- this.updateStoredFilters = function (storedFilters) {
- var datasetFilters = this.filters.state.values;
-
- for (var i = 0; i < datasetFilters.length; i++) {
- var filter = datasetFilters[i];
- var existingFilter = _.find(storedFilters, ['label', filter.label]);
- if (existingFilter && !existingFilter.display) {
- filter.display = existingFilter.display;
- this.filters.state.enabled = true;
- }
- }
- };
-
- 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;
- this.filters.state.open = false;
- this.updateStoredFilters(storedFilters.state.values);
- }
-
- var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
- if (storedSettings !== null) {
- this.settings = storedSettings;
- this.settings.open = false;
- }
- this.onSettingsRepeaterChange();
-
- var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey);
- if (storedColumnVisibility !== null) {
- this.columnVisibility = storedColumnVisibility;
- }
- };
- },
-]);
diff --git a/app/docker/containers/components/ContainersDatatable/ContainersDatatable.tsx b/app/docker/containers/components/ContainersDatatable/ContainersDatatable.tsx
new file mode 100644
index 000000000..d4183e21b
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/ContainersDatatable.tsx
@@ -0,0 +1,249 @@
+import { useEffect } from 'react';
+import {
+ useTable,
+ useSortBy,
+ useFilters,
+ useGlobalFilter,
+ usePagination,
+ Row,
+} from 'react-table';
+import { useRowSelectColumn } from '@lineup-lite/hooks';
+
+import { PaginationControls } from '@/portainer/components/pagination-controls';
+import {
+ QuickActionsSettings,
+ buildAction,
+} from '@/portainer/components/datatables/components/QuickActionsSettings';
+import {
+ Table,
+ TableActions,
+ TableContainer,
+ TableHeaderRow,
+ TableRow,
+ TableSettingsMenu,
+ TableTitle,
+ TableTitleActions,
+} from '@/portainer/components/datatables/components';
+import { multiple } from '@/portainer/components/datatables/components/filter-types';
+import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
+import { ColumnVisibilityMenu } from '@/portainer/components/datatables/components/ColumnVisibilityMenu';
+import { useRepeater } from '@/portainer/components/datatables/components/useRepeater';
+import { useDebounce } from '@/portainer/hooks/useDebounce';
+import {
+ useSearchBarContext,
+ SearchBar,
+} from '@/portainer/components/datatables/components/SearchBar';
+import type {
+ ContainersTableSettings,
+ DockerContainer,
+} from '@/docker/containers/types';
+import { useEnvironment } from '@/portainer/environments/useEnvironment';
+import { useRowSelect } from '@/portainer/components/datatables/components/useRowSelect';
+import { Checkbox } from '@/portainer/components/form-components/Checkbox';
+import { TableFooter } from '@/portainer/components/datatables/components/TableFooter';
+import { SelectedRowsCount } from '@/portainer/components/datatables/components/SelectedRowsCount';
+
+import { ContainersDatatableActions } from './ContainersDatatableActions';
+import { ContainersDatatableSettings } from './ContainersDatatableSettings';
+import { useColumns } from './columns';
+
+export interface ContainerTableProps {
+ isAddActionVisible: boolean;
+ dataset: DockerContainer[];
+ onRefresh(): Promise;
+ isHostColumnVisible: boolean;
+ autoFocusSearch: boolean;
+}
+
+export function ContainersDatatable({
+ isAddActionVisible,
+ dataset,
+ onRefresh,
+ isHostColumnVisible,
+ autoFocusSearch,
+}: ContainerTableProps) {
+ const { settings, setTableSettings } =
+ useTableSettings();
+ const [searchBarValue, setSearchBarValue] = useSearchBarContext();
+
+ const columns = useColumns();
+
+ const endpoint = useEnvironment();
+
+ useRepeater(settings.autoRefreshRate, onRefresh);
+
+ const {
+ getTableProps,
+ getTableBodyProps,
+ headerGroups,
+ page,
+ prepareRow,
+ selectedFlatRows,
+ allColumns,
+ gotoPage,
+ setPageSize,
+ setHiddenColumns,
+ toggleHideColumn,
+ setGlobalFilter,
+ state: { pageIndex, pageSize },
+ } = useTable(
+ {
+ defaultCanFilter: false,
+ columns,
+ data: dataset,
+ filterTypes: { multiple },
+ initialState: {
+ pageSize: settings.pageSize || 10,
+ hiddenColumns: settings.hiddenColumns,
+ sortBy: [settings.sortBy],
+ globalFilter: searchBarValue,
+ },
+ isRowSelectable(row: Row) {
+ return !row.original.IsPortainer;
+ },
+ selectCheckboxComponent: Checkbox,
+ },
+ useFilters,
+ useGlobalFilter,
+ useSortBy,
+ usePagination,
+ useRowSelect,
+ useRowSelectColumn
+ );
+
+ const debouncedSearchValue = useDebounce(searchBarValue);
+
+ useEffect(() => {
+ setGlobalFilter(debouncedSearchValue);
+ }, [debouncedSearchValue, setGlobalFilter]);
+
+ useEffect(() => {
+ toggleHideColumn('host', !isHostColumnVisible);
+ }, [toggleHideColumn, isHostColumnVisible]);
+
+ const columnsToHide = allColumns.filter((colInstance) => {
+ const columnDef = columns.find((c) => c.id === colInstance.id);
+ return columnDef?.canHide;
+ });
+
+ const actions = [
+ buildAction('logs', 'Logs'),
+ buildAction('inspect', 'Inspect'),
+ buildAction('stats', 'Stats'),
+ buildAction('exec', 'Console'),
+ buildAction('attach', 'Attach'),
+ ];
+
+ const tableProps = getTableProps();
+ const tbodyProps = getTableBodyProps();
+
+ return (
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+ row.original)}
+ isAddActionVisible={isAddActionVisible}
+ endpointId={endpoint.Id}
+ />
+
+
+
+
+
+
+ {headerGroups.map((headerGroup) => {
+ const { key, className, role, style } =
+ headerGroup.getHeaderGroupProps();
+
+ return (
+
+ key={key}
+ className={className}
+ role={role}
+ style={style}
+ headers={headerGroup.headers}
+ onSortChange={handleSortChange}
+ />
+ );
+ })}
+
+
+ {page.map((row) => {
+ prepareRow(row);
+ const { key, className, role, style } = row.getRowProps();
+ return (
+
+ cells={row.cells}
+ key={key}
+ className={className}
+ role={role}
+ style={style}
+ />
+ );
+ })}
+
+
+
+
+
+ gotoPage(p - 1)}
+ totalCount={dataset.length}
+ onPageLimitChange={handlePageSizeChange}
+ />
+
+
+ );
+
+ function handlePageSizeChange(pageSize: number) {
+ setPageSize(pageSize);
+ setTableSettings((settings) => ({ ...settings, pageSize }));
+ }
+
+ function handleChangeColumnsVisibility(hiddenColumns: string[]) {
+ setHiddenColumns(hiddenColumns);
+ setTableSettings((settings) => ({ ...settings, hiddenColumns }));
+ }
+
+ function handleSearchBarChange(value: string) {
+ setSearchBarValue(value);
+ }
+
+ function handleSortChange(id: string, desc: boolean) {
+ setTableSettings((settings) => ({
+ ...settings,
+ sortBy: { id, desc },
+ }));
+ }
+}
diff --git a/app/docker/containers/components/ContainersDatatable/ContainersDatatableActions.tsx b/app/docker/containers/components/ContainersDatatable/ContainersDatatableActions.tsx
new file mode 100644
index 000000000..0c0679baf
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/ContainersDatatableActions.tsx
@@ -0,0 +1,288 @@
+import { useRouter } from '@uirouter/react';
+
+import * as notifications from '@/portainer/services/notifications';
+import { useAuthorizations, Authorized } from '@/portainer/hooks/useUser';
+import { Link } from '@/portainer/components/Link';
+import { confirmContainerDeletion } from '@/portainer/services/modal.service/prompt';
+import { setPortainerAgentTargetHeader } from '@/portainer/services/http-request.helper';
+import type { ContainerId, DockerContainer } from '@/docker/containers/types';
+import {
+ killContainer,
+ pauseContainer,
+ removeContainer,
+ restartContainer,
+ resumeContainer,
+ startContainer,
+ stopContainer,
+} from '@/docker/containers/containers.service';
+import type { EnvironmentId } from '@/portainer/environments/types';
+import { ButtonGroup, Button } from '@/portainer/components/Button';
+
+type ContainerServiceAction = (
+ endpointId: EnvironmentId,
+ containerId: ContainerId
+) => Promise;
+
+interface Props {
+ selectedItems: DockerContainer[];
+ isAddActionVisible: boolean;
+ endpointId: EnvironmentId;
+}
+
+export function ContainersDatatableActions({
+ selectedItems,
+ isAddActionVisible,
+ endpointId,
+}: Props) {
+ const selectedItemCount = selectedItems.length;
+ const hasPausedItemsSelected = selectedItems.some(
+ (item) => item.Status === 'paused'
+ );
+ const hasStoppedItemsSelected = selectedItems.some((item) =>
+ ['stopped', 'created'].includes(item.Status)
+ );
+ const hasRunningItemsSelected = selectedItems.some((item) =>
+ ['running', 'healthy', 'unhealthy', 'starting'].includes(item.Status)
+ );
+
+ const isAuthorized = useAuthorizations([
+ 'DockerContainerStart',
+ 'DockerContainerStop',
+ 'DockerContainerKill',
+ 'DockerContainerRestart',
+ 'DockerContainerPause',
+ 'DockerContainerUnpause',
+ 'DockerContainerDelete',
+ 'DockerContainerCreate',
+ ]);
+
+ const router = useRouter();
+
+ if (!isAuthorized) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isAddActionVisible && (
+
+
+
+
+
+ )}
+
+ );
+
+ function onStartClick(selectedItems: DockerContainer[]) {
+ const successMessage = 'Container successfully started';
+ const errorMessage = 'Unable to start container';
+ executeActionOnContainerList(
+ selectedItems,
+ startContainer,
+ successMessage,
+ errorMessage
+ );
+ }
+
+ function onStopClick(selectedItems: DockerContainer[]) {
+ const successMessage = 'Container successfully stopped';
+ const errorMessage = 'Unable to stop container';
+ executeActionOnContainerList(
+ selectedItems,
+ stopContainer,
+ successMessage,
+ errorMessage
+ );
+ }
+
+ function onRestartClick(selectedItems: DockerContainer[]) {
+ const successMessage = 'Container successfully restarted';
+ const errorMessage = 'Unable to restart container';
+ executeActionOnContainerList(
+ selectedItems,
+ restartContainer,
+ successMessage,
+ errorMessage
+ );
+ }
+
+ function onKillClick(selectedItems: DockerContainer[]) {
+ const successMessage = 'Container successfully killed';
+ const errorMessage = 'Unable to kill container';
+ executeActionOnContainerList(
+ selectedItems,
+ killContainer,
+ successMessage,
+ errorMessage
+ );
+ }
+
+ function onPauseClick(selectedItems: DockerContainer[]) {
+ const successMessage = 'Container successfully paused';
+ const errorMessage = 'Unable to pause container';
+ executeActionOnContainerList(
+ selectedItems,
+ pauseContainer,
+ successMessage,
+ errorMessage
+ );
+ }
+
+ function onResumeClick(selectedItems: DockerContainer[]) {
+ const successMessage = 'Container successfully resumed';
+ const errorMessage = 'Unable to resume container';
+ executeActionOnContainerList(
+ selectedItems,
+ resumeContainer,
+ successMessage,
+ errorMessage
+ );
+ }
+
+ function onRemoveClick(selectedItems: DockerContainer[]) {
+ const isOneContainerRunning = selectedItems.some(
+ (container) => container.Status === 'running'
+ );
+
+ const runningTitle = isOneContainerRunning ? 'running' : '';
+ const title = `You are about to remove one or more ${runningTitle} containers.`;
+
+ confirmContainerDeletion(title, (result: string[]) => {
+ if (!result) {
+ return;
+ }
+ const cleanVolumes = !!result[0];
+
+ removeSelectedContainers(selectedItems, cleanVolumes);
+ });
+ }
+
+ async function executeActionOnContainerList(
+ containers: DockerContainer[],
+ action: ContainerServiceAction,
+ successMessage: string,
+ errorMessage: string
+ ) {
+ for (let i = 0; i < containers.length; i += 1) {
+ const container = containers[i];
+ try {
+ setPortainerAgentTargetHeader(container.NodeName);
+ await action(endpointId, container.Id);
+ notifications.success(successMessage, container.Names[0]);
+ } catch (err) {
+ notifications.error(
+ 'Failure',
+ err as Error,
+ `${errorMessage}:${container.Names[0]}`
+ );
+ }
+ }
+
+ router.stateService.reload();
+ }
+
+ async function removeSelectedContainers(
+ containers: DockerContainer[],
+ cleanVolumes: boolean
+ ) {
+ for (let i = 0; i < containers.length; i += 1) {
+ const container = containers[i];
+ try {
+ setPortainerAgentTargetHeader(container.NodeName);
+ await removeContainer(endpointId, container, cleanVolumes);
+ notifications.success(
+ 'Container successfully removed',
+ container.Names[0]
+ );
+ } catch (err) {
+ notifications.error(
+ 'Failure',
+ err as Error,
+ 'Unable to remove container'
+ );
+ }
+ }
+
+ router.stateService.reload();
+ }
+}
diff --git a/app/docker/containers/components/ContainersDatatable/ContainersDatatableContainer.tsx b/app/docker/containers/components/ContainersDatatable/ContainersDatatableContainer.tsx
new file mode 100644
index 000000000..58dc99d7d
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/ContainersDatatableContainer.tsx
@@ -0,0 +1,52 @@
+import { react2angular } from '@/react-tools/react2angular';
+import { EnvironmentProvider } from '@/portainer/environments/useEnvironment';
+import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
+import { SearchBarProvider } from '@/portainer/components/datatables/components/SearchBar';
+import type { Environment } from '@/portainer/environments/types';
+
+import {
+ ContainersDatatable,
+ ContainerTableProps,
+} from './ContainersDatatable';
+
+interface Props extends ContainerTableProps {
+ endpoint: Environment;
+}
+
+export function ContainersDatatableContainer({ endpoint, ...props }: Props) {
+ const defaultSettings = {
+ autoRefreshRate: 0,
+ truncateContainerName: 32,
+ hiddenQuickActions: [],
+ hiddenColumns: [],
+ pageSize: 10,
+ sortBy: { id: 'state', desc: false },
+ };
+
+ return (
+
+
+
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */}
+
+
+
+
+ );
+}
+
+export const ContainersDatatableAngular = react2angular(
+ ContainersDatatableContainer,
+ [
+ 'endpoint',
+ 'isAddActionVisible',
+ 'containerService',
+ 'httpRequestHelper',
+ 'notifications',
+ 'modalService',
+ 'dataset',
+ 'onRefresh',
+ 'isHostColumnVisible',
+ 'autoFocusSearch',
+ ]
+);
diff --git a/app/docker/containers/components/ContainersDatatable/ContainersDatatableSettings.tsx b/app/docker/containers/components/ContainersDatatable/ContainersDatatableSettings.tsx
new file mode 100644
index 000000000..174c790b3
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/ContainersDatatableSettings.tsx
@@ -0,0 +1,35 @@
+import { TableSettingsMenuAutoRefresh } from '@/portainer/components/datatables/components/TableSettingsMenuAutoRefresh';
+import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
+import { Checkbox } from '@/portainer/components/form-components/Checkbox';
+import type { ContainersTableSettings } from '@/docker/containers/types';
+
+export function ContainersDatatableSettings() {
+ const { settings, setTableSettings } = useTableSettings<
+ ContainersTableSettings
+ >();
+
+ return (
+ <>
+ 0}
+ onChange={() =>
+ setTableSettings((settings) => ({
+ ...settings,
+ truncateContainerName: settings.truncateContainerName > 0 ? 0 : 32,
+ }))
+ }
+ />
+
+
+ >
+ );
+
+ function handleRefreshRateChange(autoRefreshRate: number) {
+ setTableSettings({ autoRefreshRate });
+ }
+}
diff --git a/app/docker/containers/components/ContainersDatatable/columns/created.tsx b/app/docker/containers/components/ContainersDatatable/columns/created.tsx
new file mode 100644
index 000000000..15469be36
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/columns/created.tsx
@@ -0,0 +1,14 @@
+import { Column } from 'react-table';
+
+import { isoDateFromTimestamp } from '@/portainer/filters/filters';
+import type { DockerContainer } from '@/docker/containers/types';
+
+export const created: Column = {
+ Header: 'Created',
+ accessor: 'Created',
+ id: 'created',
+ Cell: ({ value }) => isoDateFromTimestamp(value),
+ disableFilters: true,
+ canHide: true,
+ Filter: () => null,
+};
diff --git a/app/docker/containers/components/ContainersDatatable/columns/host.tsx b/app/docker/containers/components/ContainersDatatable/columns/host.tsx
new file mode 100644
index 000000000..c2ac71d34
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/columns/host.tsx
@@ -0,0 +1,13 @@
+import { Column } from 'react-table';
+
+import type { DockerContainer } from '@/docker/containers/types';
+
+export const host: Column = {
+ Header: 'Host',
+ accessor: (row) => row.NodeName || '-',
+ id: 'host',
+ disableFilters: true,
+ canHide: true,
+ sortType: 'string',
+ Filter: () => null,
+};
diff --git a/app/docker/containers/components/ContainersDatatable/columns/image.tsx b/app/docker/containers/components/ContainersDatatable/columns/image.tsx
new file mode 100644
index 000000000..f2c2a1622
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/columns/image.tsx
@@ -0,0 +1,51 @@
+import { Column } from 'react-table';
+import { useSref } from '@uirouter/react';
+
+import { useEnvironment } from '@/portainer/environments/useEnvironment';
+import { EnvironmentStatus } from '@/portainer/environments/types';
+import type { DockerContainer } from '@/docker/containers/types';
+
+export const image: Column = {
+ Header: 'Image',
+ accessor: 'Image',
+ id: 'image',
+ disableFilters: true,
+ Cell: ImageCell,
+ canHide: true,
+ sortType: 'string',
+ Filter: () => null,
+};
+
+interface Props {
+ value: string;
+}
+
+function ImageCell({ value: imageName }: Props) {
+ const endpoint = useEnvironment();
+ const offlineMode = endpoint.Status !== EnvironmentStatus.Up;
+
+ const shortImageName = trimSHASum(imageName);
+
+ const linkProps = useSref('docker.images.image', { id: imageName });
+ if (offlineMode) {
+ return shortImageName;
+ }
+
+ return (
+
+ {shortImageName}
+
+ );
+
+ function trimSHASum(imageName: string) {
+ if (!imageName) {
+ return '';
+ }
+
+ if (imageName.indexOf('sha256:') === 0) {
+ return imageName.substring(7, 19);
+ }
+
+ return imageName.split('@sha256')[0];
+ }
+}
diff --git a/app/docker/containers/components/ContainersDatatable/columns/index.tsx b/app/docker/containers/components/ContainersDatatable/columns/index.tsx
new file mode 100644
index 000000000..0366a6097
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/columns/index.tsx
@@ -0,0 +1,30 @@
+import { useMemo } from 'react';
+
+import { created } from './created';
+import { host } from './host';
+import { image } from './image';
+import { ip } from './ip';
+import { name } from './name';
+import { ownership } from './ownership';
+import { ports } from './ports';
+import { quickActions } from './quick-actions';
+import { stack } from './stack';
+import { state } from './state';
+
+export function useColumns() {
+ return useMemo(
+ () => [
+ name,
+ state,
+ quickActions,
+ stack,
+ image,
+ created,
+ ip,
+ host,
+ ports,
+ ownership,
+ ],
+ []
+ );
+}
diff --git a/app/docker/containers/components/ContainersDatatable/columns/ip.tsx b/app/docker/containers/components/ContainersDatatable/columns/ip.tsx
new file mode 100644
index 000000000..76371546f
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/columns/ip.tsx
@@ -0,0 +1,12 @@
+import { Column } from 'react-table';
+
+import type { DockerContainer } from '@/docker/containers/types';
+
+export const ip: Column = {
+ Header: 'IP Address',
+ accessor: (row) => row.IP || '-',
+ id: 'ip',
+ disableFilters: true,
+ canHide: true,
+ Filter: () => null,
+};
diff --git a/app/docker/containers/components/ContainersDatatable/columns/name.tsx b/app/docker/containers/components/ContainersDatatable/columns/name.tsx
new file mode 100644
index 000000000..21d1b68a2
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/columns/name.tsx
@@ -0,0 +1,54 @@
+import { CellProps, Column, TableInstance } from 'react-table';
+import _ from 'lodash-es';
+import { useSref } from '@uirouter/react';
+
+import { useEnvironment } from '@/portainer/environments/useEnvironment';
+import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
+import type {
+ ContainersTableSettings,
+ DockerContainer,
+} from '@/docker/containers/types';
+
+export const name: Column = {
+ Header: 'Name',
+ accessor: (row) => {
+ const name = row.Names[0];
+ return name.substring(1, name.length);
+ },
+ id: 'name',
+ Cell: NameCell,
+ disableFilters: true,
+ Filter: () => null,
+ canHide: true,
+ sortType: 'string',
+};
+
+export function NameCell({
+ value: name,
+ row: { original: container },
+}: CellProps) {
+ const { settings } = useTableSettings();
+ const truncate = settings.truncateContainerName;
+ const endpoint = useEnvironment();
+ const offlineMode = endpoint.Status !== 1;
+
+ const linkProps = useSref('docker.containers.container', {
+ id: container.Id,
+ nodeName: container.NodeName,
+ });
+
+ let shortName = name;
+ if (truncate > 0) {
+ shortName = _.truncate(name, { length: truncate });
+ }
+
+ if (offlineMode) {
+ return {shortName};
+ }
+
+ return (
+
+ {shortName}
+
+ );
+}
diff --git a/app/docker/containers/components/ContainersDatatable/columns/ownership.tsx b/app/docker/containers/components/ContainersDatatable/columns/ownership.tsx
new file mode 100644
index 000000000..21b451552
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/columns/ownership.tsx
@@ -0,0 +1,34 @@
+import { Column } from 'react-table';
+import clsx from 'clsx';
+
+import { ownershipIcon } from '@/portainer/filters/filters';
+import { ResourceControlOwnership } from '@/portainer/models/resourceControl/resourceControlOwnership';
+import type { DockerContainer } from '@/docker/containers/types';
+
+export const ownership: Column = {
+ Header: 'Ownership',
+ id: 'ownership',
+ accessor: (row) =>
+ row.ResourceControl?.Ownership || ResourceControlOwnership.ADMINISTRATORS,
+ Cell: OwnershipCell,
+ disableFilters: true,
+ canHide: true,
+ sortType: 'string',
+ Filter: () => null,
+};
+
+interface Props {
+ value: 'public' | 'private' | 'restricted' | 'administrators';
+}
+
+function OwnershipCell({ value }: Props) {
+ return (
+ <>
+
+ {value || ResourceControlOwnership.ADMINISTRATORS}
+ >
+ );
+}
diff --git a/app/docker/containers/components/ContainersDatatable/columns/ports.tsx b/app/docker/containers/components/ContainersDatatable/columns/ports.tsx
new file mode 100644
index 000000000..370528559
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/columns/ports.tsx
@@ -0,0 +1,41 @@
+import { Column } from 'react-table';
+import _ from 'lodash-es';
+
+import { useEnvironment } from '@/portainer/environments/useEnvironment';
+import type { DockerContainer, Port } from '@/docker/containers/types';
+
+export const ports: Column = {
+ Header: 'Published Ports',
+ accessor: 'Ports',
+ id: 'ports',
+ Cell: PortsCell,
+ disableSortBy: true,
+ disableFilters: true,
+ canHide: true,
+ Filter: () => null,
+};
+
+interface Props {
+ value: Port[];
+}
+
+function PortsCell({ value: ports }: Props) {
+ const { PublicURL: publicUrl } = useEnvironment();
+
+ if (ports.length === 0) {
+ return '-';
+ }
+
+ return _.uniqBy(ports, 'public').map((port) => (
+
+
+ {port.public}:{port.private}
+
+ ));
+}
diff --git a/app/docker/containers/components/ContainersDatatable/columns/quick-actions.tsx b/app/docker/containers/components/ContainersDatatable/columns/quick-actions.tsx
new file mode 100644
index 000000000..04921be12
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/columns/quick-actions.tsx
@@ -0,0 +1,70 @@
+import { CellProps, Column } from 'react-table';
+
+import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
+import { useEnvironment } from '@/portainer/environments/useEnvironment';
+import { useAuthorizations } from '@/portainer/hooks/useUser';
+import { ContainerQuickActions } from '@/docker/components/container-quick-actions/ContainerQuickActions';
+import type {
+ ContainersTableSettings,
+ DockerContainer,
+} from '@/docker/containers/types';
+import { EnvironmentStatus } from '@/portainer/environments/types';
+
+export const quickActions: Column = {
+ Header: 'Quick Actions',
+ id: 'actions',
+ Cell: QuickActionsCell,
+ disableFilters: true,
+ disableSortBy: true,
+ canHide: true,
+ sortType: 'string',
+ Filter: () => null,
+};
+
+function QuickActionsCell({
+ row: { original: container },
+}: CellProps) {
+ const endpoint = useEnvironment();
+ const offlineMode = endpoint.Status !== EnvironmentStatus.Up;
+
+ const { settings } = useTableSettings();
+
+ const { hiddenQuickActions = [] } = settings;
+
+ const wrapperState = {
+ showQuickActionAttach: !hiddenQuickActions.includes('attach'),
+ showQuickActionExec: !hiddenQuickActions.includes('exec'),
+ showQuickActionInspect: !hiddenQuickActions.includes('inspect'),
+ showQuickActionLogs: !hiddenQuickActions.includes('logs'),
+ showQuickActionStats: !hiddenQuickActions.includes('stats'),
+ };
+
+ const someOn =
+ wrapperState.showQuickActionAttach ||
+ wrapperState.showQuickActionExec ||
+ wrapperState.showQuickActionInspect ||
+ wrapperState.showQuickActionLogs ||
+ wrapperState.showQuickActionStats;
+
+ const isAuthorized = useAuthorizations([
+ 'DockerContainerStats',
+ 'DockerContainerLogs',
+ 'DockerExecStart',
+ 'DockerContainerInspect',
+ 'DockerTaskInspect',
+ 'DockerTaskLogs',
+ ]);
+
+ if (offlineMode || !someOn || !isAuthorized) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/app/docker/containers/components/ContainersDatatable/columns/stack.tsx b/app/docker/containers/components/ContainersDatatable/columns/stack.tsx
new file mode 100644
index 000000000..e16a8b817
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/columns/stack.tsx
@@ -0,0 +1,13 @@
+import { Column } from 'react-table';
+
+import type { DockerContainer } from '@/docker/containers/types';
+
+export const stack: Column = {
+ Header: 'Stack',
+ accessor: (row) => row.StackName || '-',
+ id: 'stack',
+ sortType: 'string',
+ disableFilters: true,
+ canHide: true,
+ Filter: () => null,
+};
diff --git a/app/docker/containers/components/ContainersDatatable/columns/state.tsx b/app/docker/containers/components/ContainersDatatable/columns/state.tsx
new file mode 100644
index 000000000..75421dafe
--- /dev/null
+++ b/app/docker/containers/components/ContainersDatatable/columns/state.tsx
@@ -0,0 +1,60 @@
+import { Column } from 'react-table';
+import clsx from 'clsx';
+import _ from 'lodash-es';
+
+import { DefaultFilter } from '@/portainer/components/datatables/components/Filter';
+import type {
+ DockerContainer,
+ DockerContainerStatus,
+} from '@/docker/containers/types';
+
+export const state: Column = {
+ Header: 'State',
+ accessor: 'Status',
+ id: 'state',
+ Cell: StatusCell,
+ sortType: 'string',
+ filter: 'multiple',
+ Filter: DefaultFilter,
+ canHide: true,
+};
+
+function StatusCell({ value: status }: { value: DockerContainerStatus }) {
+ const statusNormalized = _.toLower(status);
+ const hasHealthCheck = ['starting', 'healthy', 'unhealthy'].includes(
+ statusNormalized
+ );
+
+ const statusClassName = getClassName();
+
+ return (
+
+ {status}
+
+ );
+
+ function getClassName() {
+ if (includeString(['paused', 'starting', 'unhealthy'])) {
+ return 'warning';
+ }
+
+ if (includeString(['created'])) {
+ return 'info';
+ }
+
+ if (includeString(['stopped', 'dead', 'exited'])) {
+ return 'danger';
+ }
+
+ return 'success';
+
+ function includeString(values: DockerContainerStatus[]) {
+ return values.some((val) => statusNormalized.includes(val));
+ }
+ }
+}
diff --git a/app/docker/containers/containers.service.ts b/app/docker/containers/containers.service.ts
new file mode 100644
index 000000000..bce1ee41d
--- /dev/null
+++ b/app/docker/containers/containers.service.ts
@@ -0,0 +1,101 @@
+import { EnvironmentId } from '@/portainer/environments/types';
+import PortainerError from '@/portainer/error';
+import axios from '@/portainer/services/axios';
+
+import { genericHandler } from '../rest/response/handlers';
+
+import { ContainerId, DockerContainer } from './types';
+
+export async function startContainer(
+ endpointId: EnvironmentId,
+ id: ContainerId
+) {
+ await axios.post(
+ urlBuilder(endpointId, id, 'start'),
+ {},
+ { transformResponse: genericHandler }
+ );
+}
+
+export async function stopContainer(
+ endpointId: EnvironmentId,
+ id: ContainerId
+) {
+ await axios.post(urlBuilder(endpointId, id, 'stop'), {});
+}
+
+export async function restartContainer(
+ endpointId: EnvironmentId,
+ id: ContainerId
+) {
+ await axios.post(urlBuilder(endpointId, id, 'restart'), {});
+}
+
+export async function killContainer(
+ endpointId: EnvironmentId,
+ id: ContainerId
+) {
+ await axios.post(urlBuilder(endpointId, id, 'kill'), {});
+}
+
+export async function pauseContainer(
+ endpointId: EnvironmentId,
+ id: ContainerId
+) {
+ await axios.post(urlBuilder(endpointId, id, 'pause'), {});
+}
+
+export async function resumeContainer(
+ endpointId: EnvironmentId,
+ id: ContainerId
+) {
+ await axios.post(urlBuilder(endpointId, id, 'unpause'), {});
+}
+
+export async function renameContainer(
+ endpointId: EnvironmentId,
+ id: ContainerId,
+ name: string
+) {
+ await axios.post(
+ urlBuilder(endpointId, id, 'rename'),
+ {},
+ { params: { name }, transformResponse: genericHandler }
+ );
+}
+
+export async function removeContainer(
+ endpointId: EnvironmentId,
+ container: DockerContainer,
+ removeVolumes: boolean
+) {
+ try {
+ const { data } = await axios.delete(
+ urlBuilder(endpointId, container.Id),
+ {
+ params: { v: removeVolumes ? 1 : 0, force: true },
+ transformResponse: genericHandler,
+ }
+ );
+
+ if (data && data.message) {
+ throw new PortainerError(data.message);
+ }
+ } catch (e) {
+ throw new PortainerError('Unable to remove container', e as Error);
+ }
+}
+
+function urlBuilder(
+ endpointId: EnvironmentId,
+ id: ContainerId,
+ action?: string
+) {
+ const url = `/endpoints/${endpointId}/docker/containers/${id}`;
+
+ if (action) {
+ return `${url}/${action}`;
+ }
+
+ return url;
+}
diff --git a/app/docker/containers/index.ts b/app/docker/containers/index.ts
new file mode 100644
index 000000000..ae78ebc14
--- /dev/null
+++ b/app/docker/containers/index.ts
@@ -0,0 +1,7 @@
+import angular from 'angular';
+
+import { ContainersDatatableAngular } from './components/ContainersDatatable/ContainersDatatableContainer';
+
+export default angular
+ .module('portainer.docker.containers', [])
+ .component('containersDatatable', ContainersDatatableAngular).name;
diff --git a/app/docker/containers/types.ts b/app/docker/containers/types.ts
new file mode 100644
index 000000000..006f2dac2
--- /dev/null
+++ b/app/docker/containers/types.ts
@@ -0,0 +1,45 @@
+import { ResourceControlViewModel } from '@/portainer/models/resourceControl/resourceControl';
+
+export type DockerContainerStatus =
+ | 'paused'
+ | 'stopped'
+ | 'created'
+ | 'healthy'
+ | 'unhealthy'
+ | 'starting'
+ | 'running'
+ | 'dead'
+ | 'exited';
+
+export type QuickAction = 'attach' | 'exec' | 'inspect' | 'logs' | 'stats';
+
+export interface ContainersTableSettings {
+ hiddenQuickActions: QuickAction[];
+ hiddenColumns: string[];
+ truncateContainerName: number;
+ autoRefreshRate: number;
+ pageSize: number;
+ sortBy: { id: string; desc: boolean };
+}
+
+export interface Port {
+ host: string;
+ public: string;
+ private: string;
+}
+
+export type ContainerId = string;
+
+export type DockerContainer = {
+ IsPortainer: boolean;
+ Status: DockerContainerStatus;
+ NodeName: string;
+ Id: ContainerId;
+ IP: string;
+ Names: string[];
+ Created: string;
+ ResourceControl: ResourceControlViewModel;
+ Ports: Port[];
+ StackName?: string;
+ Image: string;
+};
diff --git a/app/docker/rest/container.js b/app/docker/rest/container.js
index 203d16201..f98409c91 100644
--- a/app/docker/rest/container.js
+++ b/app/docker/rest/container.js
@@ -24,26 +24,6 @@ angular.module('portainer.docker').factory('Container', [
method: 'GET',
params: { action: 'json' },
},
- stop: {
- method: 'POST',
- params: { id: '@id', action: 'stop' },
- },
- restart: {
- method: 'POST',
- params: { id: '@id', action: 'restart' },
- },
- kill: {
- method: 'POST',
- params: { id: '@id', action: 'kill' },
- },
- pause: {
- method: 'POST',
- params: { id: '@id', action: 'pause' },
- },
- unpause: {
- method: 'POST',
- params: { id: '@id', action: 'unpause' },
- },
logs: {
method: 'GET',
params: { id: '@id', action: 'logs' },
@@ -60,27 +40,12 @@ angular.module('portainer.docker').factory('Container', [
params: { id: '@id', action: 'top' },
ignoreLoadingBar: true,
},
- start: {
- method: 'POST',
- params: { id: '@id', action: 'start' },
- transformResponse: genericHandler,
- },
create: {
method: 'POST',
params: { action: 'create' },
transformResponse: genericHandler,
ignoreLoadingBar: true,
},
- remove: {
- method: 'DELETE',
- params: { id: '@id', v: '@v', force: '@force' },
- transformResponse: genericHandler,
- },
- rename: {
- method: 'POST',
- params: { id: '@id', action: 'rename', name: '@name' },
- transformResponse: genericHandler,
- },
exec: {
method: 'POST',
params: { id: '@id', action: 'exec' },
diff --git a/app/docker/services/containerService.js b/app/docker/services/containerService.js
index 78fc42a68..f4fc85378 100644
--- a/app/docker/services/containerService.js
+++ b/app/docker/services/containerService.js
@@ -1,232 +1,206 @@
+import angular from 'angular';
+import {
+ killContainer,
+ pauseContainer,
+ removeContainer,
+ renameContainer,
+ restartContainer,
+ resumeContainer,
+ startContainer,
+ stopContainer,
+} from '@/docker/containers/containers.service';
import { ContainerDetailsViewModel, ContainerStatsViewModel, ContainerViewModel } from '../models/container';
-angular.module('portainer.docker').factory('ContainerService', [
- '$q',
- 'Container',
- 'ResourceControlService',
- 'LogHelper',
- '$timeout',
- function ContainerServiceFactory($q, Container, ResourceControlService, LogHelper, $timeout) {
- 'use strict';
- var service = {};
+angular.module('portainer.docker').factory('ContainerService', ContainerServiceFactory);
- service.container = function (id) {
- var deferred = $q.defer();
+/* @ngInject */
+function ContainerServiceFactory($q, Container, LogHelper, $timeout, EndpointProvider) {
+ const service = {
+ killContainer: withEndpointId(killContainer),
+ pauseContainer: withEndpointId(pauseContainer),
+ renameContainer: withEndpointId(renameContainer),
+ restartContainer: withEndpointId(restartContainer),
+ resumeContainer: withEndpointId(resumeContainer),
+ startContainer: withEndpointId(startContainer),
+ stopContainer: withEndpointId(stopContainer),
+ remove: withEndpointId(removeContainer),
+ updateRestartPolicy,
+ updateLimits,
+ };
- Container.get({ id: id })
- .$promise.then(function success(data) {
- var container = new ContainerDetailsViewModel(data);
- deferred.resolve(container);
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to retrieve container information', err: err });
+ service.container = function (id) {
+ var deferred = $q.defer();
+
+ Container.get({ id: id })
+ .$promise.then(function success(data) {
+ var container = new ContainerDetailsViewModel(data);
+ deferred.resolve(container);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to retrieve container information', err: err });
+ });
+
+ return deferred.promise;
+ };
+
+ service.containers = function (all, filters) {
+ var deferred = $q.defer();
+ Container.query({ all: all, filters: filters })
+ .$promise.then(function success(data) {
+ var containers = data.map(function (item) {
+ return new ContainerViewModel(item);
});
+ deferred.resolve(containers);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to retrieve containers', err: err });
+ });
- return deferred.promise;
- };
+ return deferred.promise;
+ };
- service.containers = function (all, filters) {
- var deferred = $q.defer();
- Container.query({ all: all, filters: filters })
- .$promise.then(function success(data) {
- var containers = data.map(function (item) {
- return new ContainerViewModel(item);
- });
- deferred.resolve(containers);
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to retrieve containers', err: err });
- });
+ service.resizeTTY = function (id, width, height, timeout) {
+ var deferred = $q.defer();
- return deferred.promise;
- };
-
- service.resizeTTY = function (id, width, height, timeout) {
- var deferred = $q.defer();
-
- $timeout(function () {
- Container.resize({}, { id: id, height: height, width: width })
- .$promise.then(function success(data) {
- if (data.message) {
- deferred.reject({ msg: 'Unable to resize tty of container ' + id, err: data.message });
- } else {
- deferred.resolve(data);
- }
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to resize tty of container ' + id, err: err });
- });
- }, timeout);
-
- return deferred.promise;
- };
-
- service.startContainer = function (id) {
- return Container.start({ id: id }, {}).$promise;
- };
-
- service.stopContainer = function (id) {
- return Container.stop({ id: id }, {}).$promise;
- };
-
- service.restartContainer = function (id) {
- return Container.restart({ id: id }, {}).$promise;
- };
-
- service.killContainer = function (id) {
- return Container.kill({ id: id }, {}).$promise;
- };
-
- service.pauseContainer = function (id) {
- return Container.pause({ id: id }, {}).$promise;
- };
-
- service.resumeContainer = function (id) {
- return Container.unpause({ id: id }, {}).$promise;
- };
-
- service.renameContainer = function (id, newContainerName) {
- return Container.rename({ id: id, name: newContainerName }, {}).$promise;
- };
-
- service.updateRestartPolicy = updateRestartPolicy;
- service.updateLimits = updateLimits;
-
- function updateRestartPolicy(id, restartPolicy, maximumRetryCounts) {
- return Container.update({ id: id }, { RestartPolicy: { Name: restartPolicy, MaximumRetryCount: maximumRetryCounts } }).$promise;
- }
-
- function updateLimits(id, config) {
- return Container.update(
- { id: id },
- {
- // MemorySwap: must be set
- // -1: non limits, 0: treated as unset(cause update error).
- MemoryReservation: config.HostConfig.MemoryReservation,
- Memory: config.HostConfig.Memory,
- MemorySwap: -1,
- NanoCpus: config.HostConfig.NanoCpus,
- }
- ).$promise;
- }
-
- service.createContainer = function (configuration) {
- var deferred = $q.defer();
- Container.create(configuration)
- .$promise.then(function success(data) {
- deferred.resolve(data);
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to create container', err: err });
- });
- return deferred.promise;
- };
-
- service.createAndStartContainer = function (configuration) {
- var deferred = $q.defer();
- var container;
- service
- .createContainer(configuration)
- .then(function success(data) {
- container = data;
- return service.startContainer(container.Id);
- })
- .then(function success() {
- deferred.resolve(container);
- })
- .catch(function error(err) {
- deferred.reject(err);
- });
- return deferred.promise;
- };
-
- service.remove = function (container, removeVolumes) {
- var deferred = $q.defer();
-
- Container.remove({ id: container.Id, v: removeVolumes ? 1 : 0, force: true })
+ $timeout(function () {
+ Container.resize({}, { id: id, height: height, width: width })
.$promise.then(function success(data) {
if (data.message) {
- deferred.reject({ msg: data.message, err: data.message });
- } else {
- deferred.resolve();
- }
- })
- .catch(function error(err) {
- deferred.reject({ msg: 'Unable to remove container', err: err });
- });
-
- return deferred.promise;
- };
-
- service.createExec = function (execConfig) {
- var deferred = $q.defer();
-
- Container.exec({}, execConfig)
- .$promise.then(function success(data) {
- if (data.message) {
- deferred.reject({ msg: data.message, err: data.message });
+ deferred.reject({ msg: 'Unable to resize tty of container ' + id, err: data.message });
} else {
deferred.resolve(data);
}
})
.catch(function error(err) {
- deferred.reject(err);
+ deferred.reject({ msg: 'Unable to resize tty of container ' + id, err: err });
});
+ }, timeout);
- return deferred.promise;
+ return deferred.promise;
+ };
+
+ function updateRestartPolicy(id, restartPolicy, maximumRetryCounts) {
+ return Container.update({ id: id }, { RestartPolicy: { Name: restartPolicy, MaximumRetryCount: maximumRetryCounts } }).$promise;
+ }
+
+ function updateLimits(id, config) {
+ return Container.update(
+ { id: id },
+ {
+ // MemorySwap: must be set
+ // -1: non limits, 0: treated as unset(cause update error).
+ MemoryReservation: config.HostConfig.MemoryReservation,
+ Memory: config.HostConfig.Memory,
+ MemorySwap: -1,
+ NanoCpus: config.HostConfig.NanoCpus,
+ }
+ ).$promise;
+ }
+
+ service.createContainer = function (configuration) {
+ var deferred = $q.defer();
+ Container.create(configuration)
+ .$promise.then(function success(data) {
+ deferred.resolve(data);
+ })
+ .catch(function error(err) {
+ deferred.reject({ msg: 'Unable to create container', err: err });
+ });
+ return deferred.promise;
+ };
+
+ service.createAndStartContainer = function (configuration) {
+ var deferred = $q.defer();
+ var container;
+ service
+ .createContainer(configuration)
+ .then(function success(data) {
+ container = data;
+ return service.startContainer(container.Id);
+ })
+ .then(function success() {
+ deferred.resolve(container);
+ })
+ .catch(function error(err) {
+ deferred.reject(err);
+ });
+ return deferred.promise;
+ };
+
+ service.createExec = function (execConfig) {
+ var deferred = $q.defer();
+
+ Container.exec({}, execConfig)
+ .$promise.then(function success(data) {
+ if (data.message) {
+ deferred.reject({ msg: data.message, err: data.message });
+ } else {
+ deferred.resolve(data);
+ }
+ })
+ .catch(function error(err) {
+ deferred.reject(err);
+ });
+
+ return deferred.promise;
+ };
+
+ service.logs = function (id, stdout, stderr, timestamps, since, tail, stripHeaders) {
+ var deferred = $q.defer();
+
+ var parameters = {
+ id: id,
+ stdout: stdout || 0,
+ stderr: stderr || 0,
+ timestamps: timestamps || 0,
+ since: since || 0,
+ tail: tail || 'all',
};
- service.logs = function (id, stdout, stderr, timestamps, since, tail, stripHeaders) {
- var deferred = $q.defer();
+ Container.logs(parameters)
+ .$promise.then(function success(data) {
+ var logs = LogHelper.formatLogs(data.logs, stripHeaders);
+ deferred.resolve(logs);
+ })
+ .catch(function error(err) {
+ deferred.reject(err);
+ });
- var parameters = {
- id: id,
- stdout: stdout || 0,
- stderr: stderr || 0,
- timestamps: timestamps || 0,
- since: since || 0,
- tail: tail || 'all',
- };
+ return deferred.promise;
+ };
- Container.logs(parameters)
- .$promise.then(function success(data) {
- var logs = LogHelper.formatLogs(data.logs, stripHeaders);
- deferred.resolve(logs);
- })
- .catch(function error(err) {
- deferred.reject(err);
- });
+ service.containerStats = function (id) {
+ var deferred = $q.defer();
- return deferred.promise;
- };
+ Container.stats({ id: id })
+ .$promise.then(function success(data) {
+ var containerStats = new ContainerStatsViewModel(data);
+ deferred.resolve(containerStats);
+ })
+ .catch(function error(err) {
+ deferred.reject(err);
+ });
- service.containerStats = function (id) {
- var deferred = $q.defer();
+ return deferred.promise;
+ };
- Container.stats({ id: id })
- .$promise.then(function success(data) {
- var containerStats = new ContainerStatsViewModel(data);
- deferred.resolve(containerStats);
- })
- .catch(function error(err) {
- deferred.reject(err);
- });
+ service.containerTop = function (id) {
+ return Container.top({ id: id }).$promise;
+ };
- return deferred.promise;
- };
+ service.inspect = function (id) {
+ return Container.inspect({ id: id }).$promise;
+ };
- service.containerTop = function (id) {
- return Container.top({ id: id }).$promise;
- };
+ service.prune = function (filters) {
+ return Container.prune({ filters: filters }).$promise;
+ };
- service.inspect = function (id) {
- return Container.inspect({ id: id }).$promise;
- };
+ return service;
- service.prune = function (filters) {
- return Container.prune({ filters: filters }).$promise;
- };
+ function withEndpointId(func) {
+ const endpointId = EndpointProvider.endpointID();
- return service;
- },
-]);
+ return func.bind(null, endpointId);
+ }
+}
diff --git a/app/docker/views/containers/containers.html b/app/docker/views/containers/containers.html
index e31258211..73cdc8b3c 100644
--- a/app/docker/views/containers/containers.html
+++ b/app/docker/views/containers/containers.html
@@ -7,19 +7,15 @@
Containers
+
diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js
index d3cc82f55..53c53a5ae 100644
--- a/app/docker/views/containers/edit/containerController.js
+++ b/app/docker/views/containers/edit/containerController.js
@@ -1,6 +1,7 @@
import moment from 'moment';
import _ from 'lodash-es';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
+import { confirmContainerDeletion } from '@/portainer/services/modal.service/prompt';
angular.module('portainer.docker').controller('ContainerController', [
'$q',
@@ -246,7 +247,8 @@ angular.module('portainer.docker').controller('ContainerController', [
if ($scope.container.State.Running) {
title = 'You are about to remove a running container.';
}
- ModalService.confirmContainerDeletion(title, function (result) {
+
+ confirmContainerDeletion(title, function (result) {
if (!result) {
return;
}
diff --git a/app/portainer/components/Button/ButtonGroup.tsx b/app/portainer/components/Button/ButtonGroup.tsx
index f807f28d8..9fd134d11 100644
--- a/app/portainer/components/Button/ButtonGroup.tsx
+++ b/app/portainer/components/Button/ButtonGroup.tsx
@@ -26,6 +26,6 @@ function sizeClass(size: Size | undefined) {
case 'large':
return 'btn-group-lg';
default:
- return 'btn-group-sm';
+ return '';
}
}
diff --git a/app/portainer/components/datatables/components/ColumnVisibilityMenu.tsx b/app/portainer/components/datatables/components/ColumnVisibilityMenu.tsx
new file mode 100644
index 000000000..051455117
--- /dev/null
+++ b/app/portainer/components/datatables/components/ColumnVisibilityMenu.tsx
@@ -0,0 +1,65 @@
+import clsx from 'clsx';
+import { Menu, MenuButton, MenuList } from '@reach/menu-button';
+import { ColumnInstance } from 'react-table';
+
+import { Checkbox } from '@/portainer/components/form-components/Checkbox';
+import type { DockerContainer } from '@/docker/containers/types';
+
+import { useTableContext } from './TableContainer';
+
+interface Props {
+ columns: ColumnInstance[];
+ onChange: (value: string[]) => void;
+ value: string[];
+}
+
+export function ColumnVisibilityMenu({ columns, onChange, value }: Props) {
+ useTableContext();
+
+ return (
+
+ );
+
+ function handleChangeColumnVisibility(colId: string, visible: boolean) {
+ if (visible) {
+ onChange(value.filter((id) => id !== colId));
+ return;
+ }
+
+ onChange([...value, colId]);
+ }
+}
diff --git a/app/portainer/components/datatables/components/Filter.tsx b/app/portainer/components/datatables/components/Filter.tsx
new file mode 100644
index 000000000..d31c62261
--- /dev/null
+++ b/app/portainer/components/datatables/components/Filter.tsx
@@ -0,0 +1,93 @@
+import clsx from 'clsx';
+import { useMemo } from 'react';
+import { Menu, MenuButton, MenuPopover } from '@reach/menu-button';
+import { ColumnInstance } from 'react-table';
+
+export function DefaultFilter({
+ column: { filterValue, setFilter, preFilteredRows, id },
+}: {
+ column: ColumnInstance;
+}) {
+ const options = useMemo(() => {
+ const options = new Set();
+ preFilteredRows.forEach((row) => {
+ options.add(row.values[id]);
+ });
+
+ return Array.from(options);
+ }, [id, preFilteredRows]);
+
+ return (
+
+ );
+}
+
+interface MultipleSelectionFilterProps {
+ options: string[];
+ value: string[];
+ filterKey: string;
+ onChange: (value: string[]) => void;
+}
+
+function MultipleSelectionFilter({
+ options,
+ value = [],
+ filterKey,
+ onChange,
+}: MultipleSelectionFilterProps) {
+ const enabled = value.length > 0;
+ return (
+
+
+
+ );
+
+ function handleChange(option: string) {
+ if (value.includes(option)) {
+ onChange(value.filter((o) => o !== option));
+
+ return;
+ }
+
+ onChange([...value, option]);
+ }
+}
diff --git a/app/portainer/components/datatables/components/QuickActionsSettings.tsx b/app/portainer/components/datatables/components/QuickActionsSettings.tsx
new file mode 100644
index 000000000..d5d34c4c4
--- /dev/null
+++ b/app/portainer/components/datatables/components/QuickActionsSettings.tsx
@@ -0,0 +1,49 @@
+import { Checkbox } from '@/portainer/components/form-components/Checkbox';
+
+import { useTableSettings } from './useTableSettings';
+
+export interface Action {
+ id: string;
+ label: string;
+}
+
+interface Props {
+ actions: Action[];
+}
+
+export interface QuickActionsSettingsType {
+ hiddenQuickActions: string[];
+}
+
+export function QuickActionsSettings({ actions }: Props) {
+ const { settings, setTableSettings } = useTableSettings<
+ QuickActionsSettingsType
+ >();
+
+ return (
+ <>
+ {actions.map(({ id, label }) => (
+ toggleAction(id, e.target.checked)}
+ />
+ ))}
+ >
+ );
+
+ function toggleAction(key: string, value: boolean) {
+ setTableSettings(({ hiddenQuickActions = [], ...settings }) => ({
+ ...settings,
+ hiddenQuickActions: value
+ ? hiddenQuickActions.filter((id) => id !== key)
+ : [...hiddenQuickActions, key],
+ }));
+ }
+}
+
+export function buildAction(id: string, label: string): Action {
+ return { id, label };
+}
diff --git a/app/portainer/components/datatables/components/SearchBar.tsx b/app/portainer/components/datatables/components/SearchBar.tsx
new file mode 100644
index 000000000..2c4f200f6
--- /dev/null
+++ b/app/portainer/components/datatables/components/SearchBar.tsx
@@ -0,0 +1,59 @@
+import { useContext, createContext, PropsWithChildren } from 'react';
+
+import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
+
+interface Props {
+ autoFocus: boolean;
+ value: string;
+ onChange(value: string): void;
+}
+
+export function SearchBar({ autoFocus, value, onChange }: Props) {
+ return (
+
+
+ onChange(e.target.value)}
+ placeholder="Search..."
+ />
+
+ );
+}
+
+const SearchBarContext = createContext<
+ [string, (value: string) => void] | null
+>(null);
+
+interface SearchBarProviderProps {
+ defaultValue?: string;
+}
+
+export function SearchBarProvider({
+ children,
+ defaultValue = '',
+}: PropsWithChildren) {
+ const [value, setValue] = useLocalStorage(
+ 'datatable_text_filter_containers',
+ defaultValue,
+ sessionStorage
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSearchBarContext() {
+ const context = useContext(SearchBarContext);
+ if (context === null) {
+ throw new Error('should be used under SearchBarProvider');
+ }
+
+ return context;
+}
diff --git a/app/portainer/components/datatables/components/SelectedRowsCount.tsx b/app/portainer/components/datatables/components/SelectedRowsCount.tsx
new file mode 100644
index 000000000..50c983270
--- /dev/null
+++ b/app/portainer/components/datatables/components/SelectedRowsCount.tsx
@@ -0,0 +1,9 @@
+interface SelectedRowsCountProps {
+ value: number;
+}
+
+export function SelectedRowsCount({ value }: SelectedRowsCountProps) {
+ return value !== 0 ? (
+ {value} item(s) selected
+ ) : null;
+}
diff --git a/app/portainer/components/datatables/components/Table.tsx b/app/portainer/components/datatables/components/Table.tsx
new file mode 100644
index 000000000..b9c054b30
--- /dev/null
+++ b/app/portainer/components/datatables/components/Table.tsx
@@ -0,0 +1,29 @@
+import clsx from 'clsx';
+import { PropsWithChildren } from 'react';
+import { TableProps } from 'react-table';
+
+import { useTableContext } from './TableContainer';
+
+export function Table({
+ children,
+ className,
+ role,
+ style,
+}: PropsWithChildren) {
+ useTableContext();
+
+ return (
+
+ );
+}
diff --git a/app/portainer/components/datatables/components/TableActions.tsx b/app/portainer/components/datatables/components/TableActions.tsx
new file mode 100644
index 000000000..eedb0e4dd
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableActions.tsx
@@ -0,0 +1,9 @@
+import { PropsWithChildren } from 'react';
+
+import { useTableContext } from './TableContainer';
+
+export function TableActions({ children }: PropsWithChildren) {
+ useTableContext();
+
+ return {children}
;
+}
diff --git a/app/portainer/components/datatables/components/TableContainer.tsx b/app/portainer/components/datatables/components/TableContainer.tsx
new file mode 100644
index 000000000..b9aef245f
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableContainer.tsx
@@ -0,0 +1,25 @@
+import { createContext, PropsWithChildren, useContext } from 'react';
+
+import { Widget, WidgetBody } from '@/portainer/components/widget';
+
+const Context = createContext(null);
+
+export function useTableContext() {
+ const context = useContext(Context);
+
+ if (context == null) {
+ throw new Error('Should be nested inside a TableContainer component');
+ }
+}
+
+export function TableContainer({ children }: PropsWithChildren) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/app/portainer/components/datatables/components/TableFooter.tsx b/app/portainer/components/datatables/components/TableFooter.tsx
new file mode 100644
index 000000000..98d2d572f
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableFooter.tsx
@@ -0,0 +1,9 @@
+import { PropsWithChildren } from 'react';
+
+import { useTableContext } from './TableContainer';
+
+export function TableFooter({ children }: PropsWithChildren) {
+ useTableContext();
+
+ return ;
+}
diff --git a/app/portainer/components/datatables/components/TableHeaderCell.module.css b/app/portainer/components/datatables/components/TableHeaderCell.module.css
new file mode 100644
index 000000000..8378bdcd2
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableHeaderCell.module.css
@@ -0,0 +1,5 @@
+.sort-icon {
+ width: 1em;
+ height: 1em;
+ display: inline-block;
+}
diff --git a/app/portainer/components/datatables/components/TableHeaderCell.tsx b/app/portainer/components/datatables/components/TableHeaderCell.tsx
new file mode 100644
index 000000000..c9e35a090
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableHeaderCell.tsx
@@ -0,0 +1,90 @@
+import clsx from 'clsx';
+import { PropsWithChildren, ReactNode } from 'react';
+import { TableHeaderProps } from 'react-table';
+
+import { Button } from '@/portainer/components/Button';
+
+import { useTableContext } from './TableContainer';
+import styles from './TableHeaderCell.module.css';
+
+interface Props {
+ canFilter: boolean;
+ canSort: boolean;
+ headerProps: TableHeaderProps;
+ isSorted: boolean;
+ isSortedDesc?: boolean;
+ onSortClick: (desc: boolean) => void;
+ render: () => ReactNode;
+ renderFilter: () => ReactNode;
+}
+
+export function TableHeaderCell({
+ headerProps: { className, role, style },
+ canSort,
+ render,
+ onSortClick,
+ isSorted,
+ isSortedDesc,
+ canFilter,
+ renderFilter,
+}: Props) {
+ useTableContext();
+
+ return (
+
+
+ {render()}
+
+ {canFilter ? renderFilter() : null}
+ |
+ );
+}
+
+interface SortWrapperProps {
+ canSort: boolean;
+ isSorted: boolean;
+ isSortedDesc?: boolean;
+ onClick: (desc: boolean) => void;
+}
+
+function SortWrapper({
+ canSort,
+ children,
+ onClick,
+ isSorted,
+ isSortedDesc,
+}: PropsWithChildren) {
+ if (!canSort) {
+ return <>{children}>;
+ }
+
+ return (
+
+ );
+}
diff --git a/app/portainer/components/datatables/components/TableHeaderRow.tsx b/app/portainer/components/datatables/components/TableHeaderRow.tsx
new file mode 100644
index 000000000..c0dcf7109
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableHeaderRow.tsx
@@ -0,0 +1,46 @@
+import { HeaderGroup, TableHeaderProps } from 'react-table';
+
+import { useTableContext } from './TableContainer';
+import { TableHeaderCell } from './TableHeaderCell';
+
+interface Props = Record> {
+ headers: HeaderGroup[];
+ onSortChange(colId: string, desc: boolean): void;
+}
+
+export function TableHeaderRow<
+ D extends Record = Record
+>({
+ headers,
+ onSortChange,
+ className,
+ role,
+ style,
+}: Props & TableHeaderProps) {
+ useTableContext();
+
+ return (
+
+ {headers.map((column) => (
+ {
+ column.toggleSortBy(desc);
+ onSortChange(column.id, desc);
+ }}
+ isSorted={column.isSorted}
+ isSortedDesc={column.isSortedDesc}
+ render={() => column.render('Header')}
+ canFilter={!column.disableFilters}
+ renderFilter={() => column.render('Filter')}
+ />
+ ))}
+
+ );
+}
diff --git a/app/portainer/components/datatables/components/TableRow.tsx b/app/portainer/components/datatables/components/TableRow.tsx
new file mode 100644
index 000000000..a3c711ac6
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableRow.tsx
@@ -0,0 +1,35 @@
+import { Cell, TableRowProps } from 'react-table';
+
+import { useTableContext } from './TableContainer';
+
+interface Props = Record>
+ extends TableRowProps {
+ cells: Cell[];
+}
+
+export function TableRow<
+ D extends Record = Record
+>({ cells, className, role, style }: Props) {
+ useTableContext();
+
+ return (
+
+ {cells.map((cell) => {
+ const cellProps = cell.getCellProps({
+ className: cell.className,
+ });
+
+ return (
+
+ {cell.render('Cell')}
+ |
+ );
+ })}
+
+ );
+}
diff --git a/app/portainer/components/datatables/components/TableSettingsMenu.tsx b/app/portainer/components/datatables/components/TableSettingsMenu.tsx
new file mode 100644
index 000000000..c82bc3b28
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableSettingsMenu.tsx
@@ -0,0 +1,44 @@
+import clsx from 'clsx';
+import { Menu, MenuButton, MenuList } from '@reach/menu-button';
+import { PropsWithChildren, ReactNode } from 'react';
+
+import { useTableContext } from './TableContainer';
+
+interface Props {
+ quickActions: ReactNode;
+}
+
+export function TableSettingsMenu({
+ quickActions,
+ children,
+}: PropsWithChildren) {
+ useTableContext();
+
+ return (
+
+ );
+}
diff --git a/app/portainer/components/datatables/components/TableSettingsMenuAutoRefresh.module.css b/app/portainer/components/datatables/components/TableSettingsMenuAutoRefresh.module.css
new file mode 100644
index 000000000..415d20916
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableSettingsMenuAutoRefresh.module.css
@@ -0,0 +1,9 @@
+.alert-visible {
+ opacity: 1;
+ transition: all 250ms linear;
+}
+
+.alert-hidden {
+ opacity: 0;
+ transition: all 250ms ease-out 2s;
+}
diff --git a/app/portainer/components/datatables/components/TableSettingsMenuAutoRefresh.tsx b/app/portainer/components/datatables/components/TableSettingsMenuAutoRefresh.tsx
new file mode 100644
index 000000000..e69276713
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableSettingsMenuAutoRefresh.tsx
@@ -0,0 +1,65 @@
+import clsx from 'clsx';
+import { useState } from 'react';
+
+import { Checkbox } from '@/portainer/components/form-components/Checkbox';
+
+import styles from './TableSettingsMenuAutoRefresh.module.css';
+
+interface Props {
+ onChange(value: number): void;
+ value: number;
+}
+
+export function TableSettingsMenuAutoRefresh({ onChange, value }: Props) {
+ const [isCheckVisible, setIsCheckVisible] = useState(false);
+
+ const isEnabled = value > 0;
+
+ return (
+ <>
+ onChange(e.target.checked ? 10 : 0)}
+ />
+
+ {isEnabled && (
+
+
+
+ setIsCheckVisible(false)}
+ >
+
+
+
+ )}
+ >
+ );
+
+ function handleChange(value: string) {
+ onChange(Number(value));
+ setIsCheckVisible(true);
+ }
+}
diff --git a/app/portainer/components/datatables/components/TableTitle.tsx b/app/portainer/components/datatables/components/TableTitle.tsx
new file mode 100644
index 000000000..7f07ca41b
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableTitle.tsx
@@ -0,0 +1,27 @@
+import clsx from 'clsx';
+import { PropsWithChildren } from 'react';
+
+import { useTableContext } from './TableContainer';
+
+interface Props {
+ icon: string;
+ label: string;
+}
+
+export function TableTitle({
+ icon,
+ label,
+ children,
+}: PropsWithChildren) {
+ useTableContext();
+
+ return (
+
+
+
+ {label}
+
+ {children}
+
+ );
+}
diff --git a/app/portainer/components/datatables/components/TableTitleActions.tsx b/app/portainer/components/datatables/components/TableTitleActions.tsx
new file mode 100644
index 000000000..b9135cdbc
--- /dev/null
+++ b/app/portainer/components/datatables/components/TableTitleActions.tsx
@@ -0,0 +1,9 @@
+import { PropsWithChildren } from 'react';
+
+import { useTableContext } from './TableContainer';
+
+export function TableTitleActions({ children }: PropsWithChildren) {
+ useTableContext();
+
+ return {children}
;
+}
diff --git a/app/portainer/components/datatables/components/filter-types.ts b/app/portainer/components/datatables/components/filter-types.ts
new file mode 100644
index 000000000..a38545eab
--- /dev/null
+++ b/app/portainer/components/datatables/components/filter-types.ts
@@ -0,0 +1,14 @@
+import { Row } from 'react-table';
+
+export function multiple<
+ D extends Record = Record
+>(rows: Row[], columnIds: string[], filterValue: string[] = []) {
+ if (filterValue.length === 0 || columnIds.length === 0) {
+ return rows;
+ }
+
+ return rows.filter((row) => {
+ const value = row.values[columnIds[0]];
+ return filterValue.includes(value);
+ });
+}
diff --git a/app/portainer/components/datatables/components/index.tsx b/app/portainer/components/datatables/components/index.tsx
new file mode 100644
index 000000000..1f32f2cb0
--- /dev/null
+++ b/app/portainer/components/datatables/components/index.tsx
@@ -0,0 +1,9 @@
+export { Table } from './Table';
+export { TableActions } from './TableActions';
+export { TableTitleActions } from './TableTitleActions';
+export { TableHeaderCell } from './TableHeaderCell';
+export { TableSettingsMenu } from './TableSettingsMenu';
+export { TableTitle } from './TableTitle';
+export { TableContainer } from './TableContainer';
+export { TableHeaderRow } from './TableHeaderRow';
+export { TableRow } from './TableRow';
diff --git a/app/portainer/components/datatables/components/useRepeater.ts b/app/portainer/components/datatables/components/useRepeater.ts
new file mode 100644
index 000000000..02c3a99ea
--- /dev/null
+++ b/app/portainer/components/datatables/components/useRepeater.ts
@@ -0,0 +1,42 @@
+import { useEffect, useCallback, useState } from 'react';
+
+export function useRepeater(
+ refreshRate: number,
+ onRefresh: () => Promise
+) {
+ const [intervalId, setIntervalId] = useState(null);
+
+ const stopRepeater = useCallback(() => {
+ if (!intervalId) {
+ return;
+ }
+
+ clearInterval(intervalId);
+ setIntervalId(null);
+ }, [intervalId]);
+
+ const startRepeater = useCallback(
+ (refreshRate) => {
+ if (intervalId) {
+ return;
+ }
+
+ setIntervalId(
+ setInterval(async () => {
+ await onRefresh();
+ }, refreshRate * 1000)
+ );
+ },
+ [intervalId]
+ );
+
+ useEffect(() => {
+ if (!refreshRate) {
+ stopRepeater();
+ } else {
+ startRepeater(refreshRate);
+ }
+
+ return stopRepeater;
+ }, [refreshRate, startRepeater, stopRepeater, intervalId]);
+}
diff --git a/app/portainer/components/datatables/components/useRowSelect.ts b/app/portainer/components/datatables/components/useRowSelect.ts
new file mode 100644
index 000000000..5c97c92e1
--- /dev/null
+++ b/app/portainer/components/datatables/components/useRowSelect.ts
@@ -0,0 +1,478 @@
+/* eslint no-param-reassign: ["error", { "props": false }] */
+import { ChangeEvent, useCallback, useMemo } from 'react';
+import {
+ actions,
+ makePropGetter,
+ ensurePluginOrder,
+ useGetLatest,
+ useMountedLayoutEffect,
+ Hooks,
+ TableInstance,
+ TableState,
+ ActionType,
+ ReducerTableState,
+ IdType,
+ Row,
+ PropGetter,
+ TableToggleRowsSelectedProps,
+ TableToggleAllRowsSelectedProps,
+} from 'react-table';
+
+type DefaultType = Record;
+
+interface UseRowSelectTableInstance
+ extends TableInstance {
+ isAllRowSelected: boolean;
+ selectSubRows: boolean;
+ getSubRows(row: Row): Row[];
+ isRowSelectable(row: Row): boolean;
+}
+
+const pluginName = 'useRowSelect';
+
+// Actions
+actions.resetSelectedRows = 'resetSelectedRows';
+actions.toggleAllRowsSelected = 'toggleAllRowsSelected';
+actions.toggleRowSelected = 'toggleRowSelected';
+actions.toggleAllPageRowsSelected = 'toggleAllPageRowsSelected';
+
+export function useRowSelect(hooks: Hooks) {
+ hooks.getToggleRowSelectedProps = [
+ defaultGetToggleRowSelectedProps as PropGetter<
+ D,
+ TableToggleRowsSelectedProps
+ >,
+ ];
+ hooks.getToggleAllRowsSelectedProps = [
+ defaultGetToggleAllRowsSelectedProps as PropGetter<
+ D,
+ TableToggleAllRowsSelectedProps
+ >,
+ ];
+ hooks.getToggleAllPageRowsSelectedProps = [
+ defaultGetToggleAllPageRowsSelectedProps as PropGetter<
+ D,
+ TableToggleAllRowsSelectedProps
+ >,
+ ];
+ hooks.stateReducers.push(
+ reducer as (
+ newState: TableState,
+ action: ActionType,
+ previousState?: TableState,
+ instance?: TableInstance
+ ) => ReducerTableState | undefined
+ );
+ hooks.useInstance.push(useInstance as (instance: TableInstance) => void);
+ hooks.prepareRow.push(prepareRow);
+}
+
+useRowSelect.pluginName = pluginName;
+
+function defaultGetToggleRowSelectedProps(
+ props: D,
+ { instance, row }: { instance: UseRowSelectTableInstance; row: Row }
+) {
+ const { manualRowSelectedKey = 'isSelected' } = instance;
+ let checked = false;
+
+ if (row.original && row.original[manualRowSelectedKey]) {
+ checked = true;
+ } else {
+ checked = row.isSelected;
+ }
+
+ return [
+ props,
+ {
+ onChange: (e: ChangeEvent) => {
+ row.toggleRowSelected(e.target.checked);
+ },
+ style: {
+ cursor: 'pointer',
+ },
+ checked,
+ title: 'Toggle Row Selected',
+ indeterminate: row.isSomeSelected,
+ disabled: !instance.isRowSelectable(row),
+ },
+ ];
+}
+
+function defaultGetToggleAllRowsSelectedProps(
+ props: D,
+ { instance }: { instance: UseRowSelectTableInstance }
+) {
+ return [
+ props,
+ {
+ onChange: (e: ChangeEvent) => {
+ instance.toggleAllRowsSelected(e.target.checked);
+ },
+ style: {
+ cursor: 'pointer',
+ },
+ checked: instance.isAllRowsSelected,
+ title: 'Toggle All Rows Selected',
+ indeterminate: Boolean(
+ !instance.isAllRowsSelected &&
+ Object.keys(instance.state.selectedRowIds).length
+ ),
+ },
+ ];
+}
+
+function defaultGetToggleAllPageRowsSelectedProps(
+ props: D,
+ { instance }: { instance: UseRowSelectTableInstance }
+) {
+ return [
+ props,
+ {
+ onChange(e: ChangeEvent) {
+ instance.toggleAllPageRowsSelected(e.target.checked);
+ },
+ style: {
+ cursor: 'pointer',
+ },
+ checked: instance.isAllPageRowsSelected,
+ title: 'Toggle All Current Page Rows Selected',
+ indeterminate: Boolean(
+ !instance.isAllPageRowsSelected &&
+ instance.page.some(({ id }) => instance.state.selectedRowIds[id])
+ ),
+ },
+ ];
+}
+
+function reducer>(
+ state: TableState,
+ action: ActionType,
+ _previousState?: TableState,
+ instance?: UseRowSelectTableInstance
+) {
+ if (action.type === actions.init) {
+ return {
+ ...state,
+ selectedRowIds: , boolean>>{},
+ };
+ }
+
+ if (action.type === actions.resetSelectedRows) {
+ return {
+ ...state,
+ selectedRowIds: instance?.initialState.selectedRowIds || {},
+ };
+ }
+
+ if (action.type === actions.toggleAllRowsSelected) {
+ const { value: setSelected } = action;
+
+ if (!instance) {
+ return state;
+ }
+
+ const {
+ isAllRowsSelected,
+ rowsById,
+ nonGroupedRowsById = rowsById,
+ isRowSelectable = defaultIsRowSelectable,
+ } = instance;
+
+ const selectAll =
+ typeof setSelected !== 'undefined' ? setSelected : !isAllRowsSelected;
+
+ // Only remove/add the rows that are visible on the screen
+ // Leave all the other rows that are selected alone.
+ const selectedRowIds = { ...state.selectedRowIds };
+
+ Object.keys(nonGroupedRowsById).forEach((rowId: IdType) => {
+ if (selectAll) {
+ const row = rowsById[rowId];
+ if (isRowSelectable(row)) {
+ selectedRowIds[rowId] = true;
+ }
+ } else {
+ delete selectedRowIds[rowId];
+ }
+ });
+
+ return {
+ ...state,
+ selectedRowIds,
+ };
+ }
+
+ if (action.type === actions.toggleRowSelected) {
+ if (!instance) {
+ return state;
+ }
+
+ const { id, value: setSelected } = action;
+ const {
+ rowsById,
+ selectSubRows = true,
+ getSubRows,
+ isRowSelectable = defaultIsRowSelectable,
+ } = instance;
+
+ const isSelected = state.selectedRowIds[id];
+ const shouldExist =
+ typeof setSelected !== 'undefined' ? setSelected : !isSelected;
+
+ if (isSelected === shouldExist) {
+ return state;
+ }
+
+ const newSelectedRowIds = { ...state.selectedRowIds };
+
+ // eslint-disable-next-line no-inner-declarations
+ function handleRowById(id: IdType) {
+ const row = rowsById[id];
+
+ if (!isRowSelectable(row)) {
+ return;
+ }
+
+ if (!row.isGrouped) {
+ if (shouldExist) {
+ newSelectedRowIds[id] = true;
+ } else {
+ delete newSelectedRowIds[id];
+ }
+ }
+
+ if (selectSubRows && getSubRows(row)) {
+ getSubRows(row).forEach((row) => handleRowById(row.id));
+ }
+ }
+
+ handleRowById(id);
+
+ return {
+ ...state,
+ selectedRowIds: newSelectedRowIds,
+ };
+ }
+
+ if (action.type === actions.toggleAllPageRowsSelected) {
+ if (!instance) {
+ return state;
+ }
+
+ const { value: setSelected } = action;
+ const {
+ page,
+ rowsById,
+ selectSubRows = true,
+ isAllPageRowsSelected,
+ getSubRows,
+ } = instance;
+
+ const selectAll =
+ typeof setSelected !== 'undefined' ? setSelected : !isAllPageRowsSelected;
+
+ const newSelectedRowIds = { ...state.selectedRowIds };
+
+ // eslint-disable-next-line no-inner-declarations
+ function handleRowById(id: IdType) {
+ const row = rowsById[id];
+
+ if (!row.isGrouped) {
+ if (selectAll) {
+ newSelectedRowIds[id] = true;
+ } else {
+ delete newSelectedRowIds[id];
+ }
+ }
+
+ if (selectSubRows && getSubRows(row)) {
+ getSubRows(row).forEach((row) => handleRowById(row.id));
+ }
+ }
+
+ page.forEach((row) => handleRowById(row.id));
+
+ return {
+ ...state,
+ selectedRowIds: newSelectedRowIds,
+ };
+ }
+ return state;
+}
+
+function useInstance>(
+ instance: UseRowSelectTableInstance
+) {
+ const {
+ data,
+ rows,
+ getHooks,
+ plugins,
+ rowsById,
+ nonGroupedRowsById = rowsById,
+ autoResetSelectedRows = true,
+ state: { selectedRowIds },
+ selectSubRows = true,
+ dispatch,
+ page,
+ getSubRows,
+ isRowSelectable,
+ } = instance;
+
+ ensurePluginOrder(
+ plugins,
+ ['useFilters', 'useGroupBy', 'useSortBy', 'useExpanded', 'usePagination'],
+ 'useRowSelect'
+ );
+
+ const selectedFlatRows = useMemo(() => {
+ const selectedFlatRows = >>[];
+
+ rows.forEach((row) => {
+ const isSelected = selectSubRows
+ ? getRowIsSelected(row, selectedRowIds, getSubRows)
+ : !!selectedRowIds[row.id];
+ row.isSelected = !!isSelected;
+ row.isSomeSelected = isSelected === null;
+
+ if (isSelected) {
+ selectedFlatRows.push(row);
+ }
+ });
+
+ return selectedFlatRows;
+ }, [rows, selectSubRows, selectedRowIds, getSubRows]);
+
+ let isAllRowsSelected = Boolean(
+ Object.keys(nonGroupedRowsById).length && Object.keys(selectedRowIds).length
+ );
+
+ let isAllPageRowsSelected = isAllRowsSelected;
+
+ if (isAllRowsSelected) {
+ if (
+ Object.keys(nonGroupedRowsById).some((id) => {
+ const row = rowsById[id];
+
+ return !selectedRowIds[id] && isRowSelectable(row);
+ })
+ ) {
+ isAllRowsSelected = false;
+ }
+ }
+
+ if (!isAllRowsSelected) {
+ if (
+ page &&
+ page.length &&
+ page.some(({ id }) => {
+ const row = rowsById[id];
+
+ return !selectedRowIds[id] && isRowSelectable(row);
+ })
+ ) {
+ isAllPageRowsSelected = false;
+ }
+ }
+
+ const getAutoResetSelectedRows = useGetLatest(autoResetSelectedRows);
+
+ useMountedLayoutEffect(() => {
+ if (getAutoResetSelectedRows()) {
+ dispatch({ type: actions.resetSelectedRows });
+ }
+ }, [dispatch, data]);
+
+ const toggleAllRowsSelected = useCallback(
+ (value) => dispatch({ type: actions.toggleAllRowsSelected, value }),
+ [dispatch]
+ );
+
+ const toggleAllPageRowsSelected = useCallback(
+ (value) => dispatch({ type: actions.toggleAllPageRowsSelected, value }),
+ [dispatch]
+ );
+
+ const toggleRowSelected = useCallback(
+ (id, value) => dispatch({ type: actions.toggleRowSelected, id, value }),
+ [dispatch]
+ );
+
+ const getInstance = useGetLatest(instance);
+
+ const getToggleAllRowsSelectedProps = makePropGetter(
+ getHooks().getToggleAllRowsSelectedProps,
+ { instance: getInstance() }
+ );
+
+ const getToggleAllPageRowsSelectedProps = makePropGetter(
+ getHooks().getToggleAllPageRowsSelectedProps,
+ { instance: getInstance() }
+ );
+
+ Object.assign(instance, {
+ selectedFlatRows,
+ isAllRowsSelected,
+ isAllPageRowsSelected,
+ toggleRowSelected,
+ toggleAllRowsSelected,
+ getToggleAllRowsSelectedProps,
+ getToggleAllPageRowsSelectedProps,
+ toggleAllPageRowsSelected,
+ });
+}
+
+function prepareRow>(
+ row: Row,
+ { instance }: { instance: TableInstance }
+) {
+ row.toggleRowSelected = (set) => instance.toggleRowSelected(row.id, set);
+
+ row.getToggleRowSelectedProps = makePropGetter(
+ instance.getHooks().getToggleRowSelectedProps,
+ { instance, row }
+ );
+}
+
+function getRowIsSelected>(
+ row: Row,
+ selectedRowIds: Record, boolean>,
+ getSubRows: (row: Row) => Array>
+) {
+ if (selectedRowIds[row.id]) {
+ return true;
+ }
+
+ const subRows = getSubRows(row);
+
+ if (subRows && subRows.length) {
+ let allChildrenSelected = true;
+ let someSelected = false;
+
+ subRows.forEach((subRow) => {
+ // Bail out early if we know both of these
+ if (someSelected && !allChildrenSelected) {
+ return;
+ }
+
+ if (getRowIsSelected(subRow, selectedRowIds, getSubRows)) {
+ someSelected = true;
+ } else {
+ allChildrenSelected = false;
+ }
+ });
+
+ if (allChildrenSelected) {
+ return true;
+ }
+
+ return someSelected ? null : false;
+ }
+
+ return false;
+}
+
+function defaultIsRowSelectable(row: Row) {
+ return !!row.original.disabled;
+}
diff --git a/app/portainer/components/datatables/components/useTableSettings.tsx b/app/portainer/components/datatables/components/useTableSettings.tsx
new file mode 100644
index 000000000..a2de1363f
--- /dev/null
+++ b/app/portainer/components/datatables/components/useTableSettings.tsx
@@ -0,0 +1,77 @@
+import { Context, createContext, ReactNode, useContext, useState } from 'react';
+
+import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
+
+export interface TableSettingsContextInterface {
+ settings: T;
+ setTableSettings(partialSettings: Partial): void;
+ setTableSettings(mutation: (settings: T) => T): void;
+}
+
+const TableSettingsContext = createContext
+> | null>(null);
+
+export function useTableSettings() {
+ const Context = getContextType();
+
+ const context = useContext(Context);
+
+ if (context === null) {
+ throw new Error('must be nested under TableSettingsProvider');
+ }
+
+ return context;
+}
+
+interface ProviderProps {
+ children: ReactNode;
+ defaults?: T;
+ storageKey: string;
+}
+
+export function TableSettingsProvider({
+ children,
+ defaults,
+ storageKey,
+}: ProviderProps) {
+ const Context = getContextType();
+
+ const [storage, setStorage] = useLocalStorage(
+ keyBuilder(storageKey),
+ defaults as T
+ );
+
+ const [settings, setTableSettings] = useState(storage);
+
+ return (
+
+ {children}
+
+ );
+
+ function handleChange(partialSettings: T): void;
+ function handleChange(mutation: (settings: T) => T): void;
+ function handleChange(mutation: T | ((settings: T) => T)): void {
+ setTableSettings((settings) => {
+ const newTableSettings =
+ mutation instanceof Function
+ ? mutation(settings)
+ : { ...settings, ...mutation };
+
+ setStorage(newTableSettings);
+
+ return newTableSettings;
+ });
+ }
+
+ function keyBuilder(key: string) {
+ return `datatable_TableSettings_${key}`;
+ }
+}
+
+function getContextType() {
+ return (TableSettingsContext as unknown) as Context<
+ TableSettingsContextInterface
+ >;
+}
diff --git a/app/portainer/components/datatables/datatable.css b/app/portainer/components/datatables/datatable.css
index c6ae521da..20179869d 100644
--- a/app/portainer/components/datatables/datatable.css
+++ b/app/portainer/components/datatables/datatable.css
@@ -126,6 +126,10 @@
color: #767676;
cursor: pointer;
font-size: 12px !important;
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
}
.widget .widget-body table thead th .filter-active {
@@ -252,3 +256,25 @@
transform: scale(0);
width: 8px;
}
+
+.table th.selection,
+.table td.selection {
+ width: 30px;
+}
+
+.table th button.sortable {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ color: var(--text-link-color);
+}
+
+.table th button.sortable:hover .sortable-label {
+ text-decoration: underline;
+}
+
+.datatable .table-setting-menu-btn {
+ border: none;
+ background: none;
+}
diff --git a/app/portainer/components/form-components/Checkbox.tsx b/app/portainer/components/form-components/Checkbox.tsx
new file mode 100644
index 000000000..1bec896e2
--- /dev/null
+++ b/app/portainer/components/form-components/Checkbox.tsx
@@ -0,0 +1,57 @@
+import {
+ forwardRef,
+ useRef,
+ useEffect,
+ MutableRefObject,
+ ChangeEventHandler,
+ HTMLProps,
+} from 'react';
+
+interface Props extends HTMLProps {
+ checked?: boolean;
+ indeterminate?: boolean;
+ title?: string;
+ label?: string;
+ id: string;
+ className?: string;
+ role?: string;
+ onChange?: ChangeEventHandler;
+}
+
+export const Checkbox = forwardRef(
+ (
+ { indeterminate, title, label, id, checked, onChange, ...props }: Props,
+ ref
+ ) => {
+ const defaultRef = useRef(null);
+ let resolvedRef = ref as MutableRefObject;
+ if (!ref) {
+ resolvedRef = defaultRef;
+ }
+
+ useEffect(() => {
+ if (resolvedRef === null || resolvedRef.current === null) {
+ return;
+ }
+
+ if (typeof indeterminate !== 'undefined') {
+ resolvedRef.current.indeterminate = indeterminate;
+ }
+ }, [resolvedRef, indeterminate]);
+
+ return (
+
+
+
+
+ );
+ }
+);
diff --git a/app/portainer/components/pagination-controls/ItemsPerPageSelector.tsx b/app/portainer/components/pagination-controls/ItemsPerPageSelector.tsx
new file mode 100644
index 000000000..20ad2e0c9
--- /dev/null
+++ b/app/portainer/components/pagination-controls/ItemsPerPageSelector.tsx
@@ -0,0 +1,26 @@
+interface Props {
+ value: number;
+ onChange(value: number): void;
+ showAll: boolean;
+}
+
+export function ItemsPerPageSelector({ value, onChange, showAll }: Props) {
+ return (
+
+ Items per page
+
+
+ );
+}
diff --git a/app/portainer/components/pagination-controls/PageButton.tsx b/app/portainer/components/pagination-controls/PageButton.tsx
new file mode 100644
index 000000000..6c8080c7f
--- /dev/null
+++ b/app/portainer/components/pagination-controls/PageButton.tsx
@@ -0,0 +1,29 @@
+import clsx from 'clsx';
+import { ReactNode } from 'react';
+
+interface Props {
+ active?: boolean;
+ children: ReactNode;
+ disabled?: boolean;
+ onPageChange(page: number): void;
+ page: number | '...';
+}
+
+export function PageButton({
+ children,
+ page,
+ disabled,
+ active,
+ onPageChange,
+}: Props) {
+ return (
+
+
+
+ );
+}
diff --git a/app/portainer/components/pagination-controls/PageSelector.tsx b/app/portainer/components/pagination-controls/PageSelector.tsx
new file mode 100644
index 000000000..f298ec303
--- /dev/null
+++ b/app/portainer/components/pagination-controls/PageSelector.tsx
@@ -0,0 +1,87 @@
+import { generatePagesArray } from './generatePagesArray';
+import { PageButton } from './PageButton';
+
+interface Props {
+ boundaryLinks?: boolean;
+ currentPage: number;
+ directionLinks?: boolean;
+ itemsPerPage: number;
+ onPageChange(page: number): void;
+ totalCount: number;
+ maxSize: number;
+}
+
+export function PageSelector({
+ currentPage,
+ totalCount,
+ itemsPerPage,
+ onPageChange,
+ maxSize = 5,
+ directionLinks = true,
+ boundaryLinks = false,
+}: Props) {
+ const pages = generatePagesArray(
+ currentPage,
+ totalCount,
+ itemsPerPage,
+ maxSize
+ );
+ const last = pages[pages.length - 1];
+
+ if (pages.length <= 1) {
+ return null;
+ }
+
+ return (
+
+ {boundaryLinks ? (
+
+ «
+
+ ) : null}
+ {directionLinks ? (
+
+ ‹
+
+ ) : null}
+ {pages.map((pageNumber, index) => (
+
+ {pageNumber}
+
+ ))}
+
+ {directionLinks ? (
+
+ ›
+
+ ) : null}
+ {boundaryLinks ? (
+
+ »
+
+ ) : null}
+
+ );
+}
diff --git a/app/portainer/components/pagination-controls/PaginationControls.tsx b/app/portainer/components/pagination-controls/PaginationControls.tsx
new file mode 100644
index 000000000..83c9a0be4
--- /dev/null
+++ b/app/portainer/components/pagination-controls/PaginationControls.tsx
@@ -0,0 +1,46 @@
+import { ItemsPerPageSelector } from './ItemsPerPageSelector';
+import { PageSelector } from './PageSelector';
+
+interface Props {
+ onPageChange(page: number): void;
+ onPageLimitChange(value: number): void;
+ page: number;
+ pageLimit: number;
+ showAll: boolean;
+ totalCount: number;
+}
+
+export function PaginationControls({
+ pageLimit,
+ page,
+ onPageLimitChange,
+ showAll,
+ onPageChange,
+ totalCount,
+}: Props) {
+ return (
+
+ );
+
+ function handlePageLimitChange(value: number) {
+ onPageLimitChange(value);
+ onPageChange(1);
+ }
+}
diff --git a/app/portainer/components/pagination-controls/calculatePageNumber.ts b/app/portainer/components/pagination-controls/calculatePageNumber.ts
new file mode 100644
index 000000000..255729071
--- /dev/null
+++ b/app/portainer/components/pagination-controls/calculatePageNumber.ts
@@ -0,0 +1,37 @@
+/**
+ * Given the position in the sequence of pagination links, figure out what page number corresponds to that position.
+ *
+ * @param position
+ * @param currentPage
+ * @param paginationRange
+ * @param totalPages
+ */
+export function calculatePageNumber(
+ position: number,
+ currentPage: number,
+ paginationRange: number,
+ totalPages: number
+) {
+ const halfWay = Math.ceil(paginationRange / 2);
+ if (position === paginationRange) {
+ return totalPages;
+ }
+
+ if (position === 1) {
+ return position;
+ }
+
+ if (paginationRange < totalPages) {
+ if (totalPages - halfWay < currentPage) {
+ return totalPages - paginationRange + position;
+ }
+
+ if (halfWay < currentPage) {
+ return currentPage - halfWay + position;
+ }
+
+ return position;
+ }
+
+ return position;
+}
diff --git a/app/portainer/components/pagination-controls/generatePagesArray.ts b/app/portainer/components/pagination-controls/generatePagesArray.ts
new file mode 100644
index 000000000..6696be362
--- /dev/null
+++ b/app/portainer/components/pagination-controls/generatePagesArray.ts
@@ -0,0 +1,55 @@
+import { calculatePageNumber } from './calculatePageNumber';
+
+export /**
+ * Generate an array of page numbers (or the '...' string) which is used in an ng-repeat to generate the
+ * links used in pagination
+ *
+ * @param currentPage
+ * @param rowsPerPage
+ * @param paginationRange
+ * @param collectionLength
+ * @returns {Array}
+ */
+function generatePagesArray(
+ currentPage: number,
+ collectionLength: number,
+ rowsPerPage: number,
+ paginationRange: number
+): (number | '...')[] {
+ const pages: (number | '...')[] = [];
+ const totalPages = Math.ceil(collectionLength / rowsPerPage);
+ const halfWay = Math.ceil(paginationRange / 2);
+
+ let position;
+ if (currentPage <= halfWay) {
+ position = 'start';
+ } else if (totalPages - halfWay < currentPage) {
+ position = 'end';
+ } else {
+ position = 'middle';
+ }
+
+ const ellipsesNeeded = paginationRange < totalPages;
+
+ for (let i = 1; i <= totalPages && i <= paginationRange; i += 1) {
+ const pageNumber = calculatePageNumber(
+ i,
+ currentPage,
+ paginationRange,
+ totalPages
+ );
+
+ const openingEllipsesNeeded =
+ i === 2 && (position === 'middle' || position === 'end');
+ const closingEllipsesNeeded =
+ i === paginationRange - 1 &&
+ (position === 'middle' || position === 'start');
+ if (ellipsesNeeded && (openingEllipsesNeeded || closingEllipsesNeeded)) {
+ pages.push('...');
+ } else {
+ pages.push(pageNumber);
+ }
+ }
+
+ return pages;
+}
diff --git a/app/portainer/components/pagination-controls/index.ts b/app/portainer/components/pagination-controls/index.ts
new file mode 100644
index 000000000..73d59f358
--- /dev/null
+++ b/app/portainer/components/pagination-controls/index.ts
@@ -0,0 +1,3 @@
+import './pagination-controls.css';
+
+export { PaginationControls } from './PaginationControls';
diff --git a/app/portainer/components/pagination-controls/pagination-controls.css b/app/portainer/components/pagination-controls/pagination-controls.css
new file mode 100644
index 000000000..5bd7cf3e2
--- /dev/null
+++ b/app/portainer/components/pagination-controls/pagination-controls.css
@@ -0,0 +1,72 @@
+.pagination-controls {
+ margin-left: 10px;
+}
+
+.paginationControls form.form-inline {
+ display: flex;
+}
+
+.pagination > li:first-child > button {
+ margin-left: 0;
+ border-top-left-radius: 4px;
+ border-bottom-left-radius: 4px;
+}
+
+.pagination > .disabled > span,
+.pagination > .disabled > span:hover,
+.pagination > .disabled > span:focus,
+.pagination > .disabled > button,
+.pagination > .disabled > button:hover,
+.pagination > .disabled > button:focus,
+.pagination > .disabled > a,
+.pagination > .disabled > a:hover,
+.pagination > .disabled > a:focus {
+ color: var(--text-pagination-color);
+ background-color: var(--bg-pagination-color);
+ border-color: var(--border-pagination-color);
+}
+
+.pagination > li > button {
+ position: relative;
+ float: left;
+ padding: 6px 12px;
+ margin-left: -1px !important;
+ line-height: 1.42857143;
+ text-decoration: none;
+ border: 1px solid #ddd;
+}
+
+.pagination > li > a,
+.pagination > li > button,
+.pagination > li > span {
+ background-color: var(--bg-pagination-span-color);
+ border-color: var(--border-pagination-span-color);
+ color: var(--text-pagination-span-color);
+}
+
+.pagination > li > a:hover,
+.pagination > li > button:hover,
+.pagination > li > span:hover,
+.pagination > li > a:focus,
+.pagination > li > button:focus,
+.pagination > li > span:focus {
+ background-color: var(--bg-pagination-hover-color);
+ border-color: var(--border-pagination-hover-color);
+ color: var(--text-pagination-span-hover-color);
+}
+
+.pagination > .active > a,
+.pagination > .active > span,
+.pagination > .active > button,
+.pagination > .active > a:hover,
+.pagination > .active > span:hover,
+.pagination > .active > button:hover,
+.pagination > .active > a:focus,
+.pagination > .active > span:focus,
+.pagination > .active > button:focus {
+ z-index: 3;
+ color: #fff;
+ cursor: default;
+ background-color: var(--text-pagination-span-color);
+ border-color: var(--text-pagination-span-color);
+}
diff --git a/app/portainer/environments/types.ts b/app/portainer/environments/types.ts
new file mode 100644
index 000000000..4be0fa67e
--- /dev/null
+++ b/app/portainer/environments/types.ts
@@ -0,0 +1,12 @@
+export type EnvironmentId = number;
+
+export enum EnvironmentStatus {
+ Up = 1,
+ Down = 2,
+}
+
+export interface Environment {
+ Id: EnvironmentId;
+ Status: EnvironmentStatus;
+ PublicURL: string;
+}
diff --git a/app/portainer/environments/useEnvironment.tsx b/app/portainer/environments/useEnvironment.tsx
new file mode 100644
index 000000000..8565b7acb
--- /dev/null
+++ b/app/portainer/environments/useEnvironment.tsx
@@ -0,0 +1,27 @@
+import { createContext, ReactNode, useContext } from 'react';
+
+import type { Environment } from './types';
+
+const EnvironmentContext = createContext(null);
+
+export function useEnvironment() {
+ const context = useContext(EnvironmentContext);
+ if (context === null) {
+ throw new Error('must be nested under EnvironmentProvider');
+ }
+
+ return context;
+}
+
+interface Props {
+ children: ReactNode;
+ environment: Environment;
+}
+
+export function EnvironmentProvider({ children, environment }: Props) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/portainer/error.js b/app/portainer/error.js
deleted file mode 100644
index 88c57d72b..000000000
--- a/app/portainer/error.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export default class PortainerError {
- constructor(msg, err) {
- this.msg = msg;
- this.err = err;
- }
-}
diff --git a/app/portainer/error.ts b/app/portainer/error.ts
new file mode 100644
index 000000000..f99dc514a
--- /dev/null
+++ b/app/portainer/error.ts
@@ -0,0 +1,8 @@
+export default class PortainerError extends Error {
+ err?: Error;
+
+ constructor(msg: string, err?: Error) {
+ super(msg);
+ this.err = err;
+ }
+}
diff --git a/app/portainer/hooks/useDebounce.ts b/app/portainer/hooks/useDebounce.ts
new file mode 100644
index 000000000..f26df6d1a
--- /dev/null
+++ b/app/portainer/hooks/useDebounce.ts
@@ -0,0 +1,15 @@
+import { useEffect, useState } from 'react';
+
+export function useDebounce(value: T, delay = 500): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delay);
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/app/portainer/hooks/useUser.tsx b/app/portainer/hooks/useUser.tsx
index 2a39e3392..bd1e070c4 100644
--- a/app/portainer/hooks/useUser.tsx
+++ b/app/portainer/hooks/useUser.tsx
@@ -71,13 +71,10 @@ interface AuthorizedProps {
children: ReactNode;
}
-export function Authorized({
- authorizations,
- children,
-}: AuthorizedProps): ReactNode {
+export function Authorized({ authorizations, children }: AuthorizedProps) {
const isAllowed = useAuthorizations(authorizations);
- return isAllowed ? children : null;
+ return isAllowed ? <>{children}> : null;
}
interface UserProviderProps {
diff --git a/app/react-table-config.d.ts b/app/react-table-config.d.ts
new file mode 100644
index 000000000..f40127e96
--- /dev/null
+++ b/app/react-table-config.d.ts
@@ -0,0 +1,156 @@
+import {
+ UseColumnOrderInstanceProps,
+ UseColumnOrderState,
+ UseExpandedHooks,
+ UseExpandedInstanceProps,
+ UseExpandedOptions,
+ UseExpandedRowProps,
+ UseExpandedState,
+ UseFiltersColumnOptions,
+ UseFiltersColumnProps,
+ UseFiltersInstanceProps,
+ UseFiltersOptions,
+ UseFiltersState,
+ UseGlobalFiltersColumnOptions,
+ UseGlobalFiltersInstanceProps,
+ UseGlobalFiltersOptions,
+ UseGlobalFiltersState,
+ UseGroupByCellProps,
+ UseGroupByColumnOptions,
+ UseGroupByColumnProps,
+ UseGroupByHooks,
+ UseGroupByInstanceProps,
+ UseGroupByOptions,
+ UseGroupByRowProps,
+ UseGroupByState,
+ UsePaginationInstanceProps,
+ UsePaginationOptions,
+ UsePaginationState,
+ UseResizeColumnsColumnOptions,
+ UseResizeColumnsColumnProps,
+ UseResizeColumnsOptions,
+ UseResizeColumnsState,
+ UseRowSelectHooks,
+ UseRowSelectInstanceProps,
+ UseRowSelectOptions,
+ UseRowSelectRowProps,
+ UseRowSelectState,
+ UseRowStateCellProps,
+ UseRowStateInstanceProps,
+ UseRowStateOptions,
+ UseRowStateRowProps,
+ UseRowStateState,
+ UseSortByColumnOptions,
+ UseSortByColumnProps,
+ UseSortByHooks,
+ UseSortByInstanceProps,
+ UseSortByOptions,
+ UseSortByState,
+} from 'react-table';
+import { UseSelectColumnTableOptions } from '@lineup-lite/hooks';
+
+declare module 'react-table' {
+ // take this file as-is, or comment out the sections that don't apply to your plugin configuration
+
+ export interface TableOptions>
+ extends UseExpandedOptions,
+ UseFiltersOptions,
+ UseGlobalFiltersOptions,
+ UseGroupByOptions,
+ UsePaginationOptions,
+ UseResizeColumnsOptions,
+ UseRowSelectOptions,
+ UseRowStateOptions,
+ UseSortByOptions,
+ UseSelectColumnTableOptions,
+ // note that having Record here allows you to add anything to the options, this matches the spirit of the
+ // underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
+ // feature set, this is a safe default.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ Record {}
+
+ export interface Hooks<
+ D extends Record = Record
+ >
+ extends UseExpandedHooks,
+ UseGroupByHooks,
+ UseRowSelectHooks,
+ UseSortByHooks {}
+
+ export interface TableInstance<
+ D extends Record = Record
+ >
+ extends UseColumnOrderInstanceProps,
+ UseExpandedInstanceProps,
+ UseFiltersInstanceProps,
+ UseGlobalFiltersInstanceProps,
+ UseGroupByInstanceProps,
+ UsePaginationInstanceProps,
+ UseRowSelectInstanceProps,
+ UseRowStateInstanceProps,
+ UseSortByInstanceProps {}
+
+ export interface TableState<
+ D extends Record = Record
+ >
+ extends UseColumnOrderState,
+ UseExpandedState,
+ UseFiltersState,
+ UseGlobalFiltersState,
+ UseGroupByState,
+ UsePaginationState,
+ UseResizeColumnsState,
+ UseRowSelectState,
+ UseRowStateState,
+ UseSortByState {}
+
+ export interface ColumnInterface<
+ D extends Record = Record
+ >
+ extends UseFiltersColumnOptions,
+ UseGlobalFiltersColumnOptions,
+ UseGroupByColumnOptions,
+ UseResizeColumnsColumnOptions,
+ UseSortByColumnOptions {
+ className?: string;
+ canHide?: boolean;
+ }
+
+ export interface ColumnInstance<
+ D extends Record = Record
+ >
+ extends UseFiltersColumnProps,
+ UseGroupByColumnProps,
+ UseResizeColumnsColumnProps,
+ UseSortByColumnProps {
+ className?: string;
+ }
+
+ export interface Cell<
+ D extends Record = Record,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ V = any
+ >
+ extends UseTableCellProps,
+ UseGroupByCellProps,
+ UseRowStateCellProps {
+ className?: string;
+ }
+
+ export interface Row<
+ D extends Record = Record
+ >
+ extends UseExpandedRowProps,
+ UseGroupByRowProps,
+ UseRowSelectRowProps,
+ UseRowStateRowProps {}
+
+ export function makePropGetter(
+ hooks: Array,
+ ...meta: Record[]
+ ): PropGetter;
+
+ export interface TableToggleRowsSelectedProps {
+ disabled: boolean;
+ }
+}
diff --git a/package.json b/package.json
index 41be9df38..962d5690c 100644
--- a/package.json
+++ b/package.json
@@ -66,7 +66,9 @@
"dependencies": {
"@aws-crypto/sha256-js": "^2.0.0",
"@fortawesome/fontawesome-free": "^5.15.4",
+ "@lineup-lite/hooks": "^1.6.0",
"@nxmix/tokenize-ansi": "^3.0.0",
+ "@reach/menu-button": "^0.16.1",
"@uirouter/angularjs": "1.0.11",
"@uirouter/react": "^1.0.7",
"@uirouter/react-hybrid": "^1.0.4",
@@ -113,7 +115,9 @@
"rc-slider": "^9.7.5",
"react": "^17.0.2",
"react-dom": "^17.0.2",
+ "react-is": "^17.0.2",
"react-select": "^5.2.1",
+ "react-table": "^7.7.0",
"react-tooltip": "^4.2.21",
"sanitize-html": "^2.5.3",
"spinkit": "^2.0.1",
@@ -151,6 +155,7 @@
"@types/lodash-es": "^4.17.5",
"@types/react": "^17.0.37",
"@types/react-dom": "^17.0.11",
+ "@types/react-table": "^7.7.6",
"@types/sanitize-html": "^2.5.0",
"@types/toastr": "^2.1.39",
"@typescript-eslint/eslint-plugin": "^5.7.0",
diff --git a/yarn.lock b/yarn.lock
index ead459a38..320651b6c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1705,6 +1705,21 @@
resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796"
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
+"@lineup-lite/components@~1.6.0":
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/@lineup-lite/components/-/components-1.6.0.tgz#e1a9d368e1c3851f312a3e667e8f3eda52d9f0fc"
+ integrity sha512-DVif6MrAYBYLok5pUGtWBoVRxyCvb6ZOmGrLm3B4wxdo1lh2Gw8Io7cCsXfat2BdUsbtCJwUh9e+NbaqDWGNGA==
+ dependencies:
+ "@sgratzl/boxplots" "^1.2.2"
+
+"@lineup-lite/hooks@^1.6.0":
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/@lineup-lite/hooks/-/hooks-1.6.0.tgz#d4e7fbb6912b88e5bd31d98c52f183f11424f48d"
+ integrity sha512-PWWN9/va4TaCwx6wcE98swaCk385YQr7Bjgl7mnRvYNfP7m3JlHk7gASBQXNl6FLJrs98uyTOW032+OZwBYu5A==
+ dependencies:
+ "@lineup-lite/components" "~1.6.0"
+ date-fns "^2.21.3"
+
"@mdx-js/loader@^1.6.22":
version "1.6.22"
resolved "https://registry.npmjs.org/@mdx-js/loader/-/loader-1.6.22.tgz"
@@ -1829,6 +1844,93 @@
resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.10.2.tgz"
integrity sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ==
+"@reach/auto-id@0.16.0":
+ version "0.16.0"
+ resolved "https://registry.yarnpkg.com/@reach/auto-id/-/auto-id-0.16.0.tgz#dfabc3227844e8c04f8e6e45203a8e14a8edbaed"
+ integrity sha512-5ssbeP5bCkM39uVsfQCwBBL+KT8YColdnMN5/Eto6Rj7929ql95R3HZUOkKIvj7mgPtEb60BLQxd1P3o6cjbmg==
+ dependencies:
+ "@reach/utils" "0.16.0"
+ tslib "^2.3.0"
+
+"@reach/descendants@0.16.1":
+ version "0.16.1"
+ resolved "https://registry.yarnpkg.com/@reach/descendants/-/descendants-0.16.1.tgz#fa3d89c0503565369707f32985d87eef61985d9f"
+ integrity sha512-3WZgRnD9O4EORKE31rrduJDiPFNMOjUkATx0zl192ZxMq3qITe4tUj70pS5IbJl/+v9zk78JwyQLvA1pL7XAPA==
+ dependencies:
+ "@reach/utils" "0.16.0"
+ tslib "^2.3.0"
+
+"@reach/dropdown@0.16.1":
+ version "0.16.1"
+ resolved "https://registry.yarnpkg.com/@reach/dropdown/-/dropdown-0.16.1.tgz#0af4fd17e590ca852f6bb27f34d525f07510a326"
+ integrity sha512-56ITaEAWYFvEYSJz0RqFndrHIDhSuhmobmgB4Wy1q6Aj/3F20Bns1mGu61/ny5Ld8spgP1mJTJViacHsn7x/Dg==
+ dependencies:
+ "@reach/auto-id" "0.16.0"
+ "@reach/descendants" "0.16.1"
+ "@reach/popover" "0.16.0"
+ "@reach/utils" "0.16.0"
+ tslib "^2.3.0"
+
+"@reach/menu-button@^0.16.1":
+ version "0.16.1"
+ resolved "https://registry.yarnpkg.com/@reach/menu-button/-/menu-button-0.16.1.tgz#a81ab9d2ee21874bf8d957b29a388b8daa2b7cfd"
+ integrity sha512-0Oh/Oq+GupS48LuBdIu8VyMJFLlWReRAGgT24SrcAAxmlyO/qUP9KJS9D1CEvl2KUoSk2wO0Fu885E4eaBYucA==
+ dependencies:
+ "@reach/dropdown" "0.16.1"
+ "@reach/popover" "0.16.0"
+ "@reach/utils" "0.16.0"
+ prop-types "^15.7.2"
+ tiny-warning "^1.0.3"
+ tslib "^2.3.0"
+
+"@reach/observe-rect@1.2.0":
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2"
+ integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==
+
+"@reach/popover@0.16.0":
+ version "0.16.0"
+ resolved "https://registry.yarnpkg.com/@reach/popover/-/popover-0.16.0.tgz#82c5ab96a88c49e2451a9c04b2d4392a9055f623"
+ integrity sha512-xmgiSyQwfshMkMNu6URbGrjjDTD3dnAITojvgEqfEtV1chDYqktKdDbIPrq+UGI54ey/IxbRpVzKcIjXiKoMmA==
+ dependencies:
+ "@reach/portal" "0.16.0"
+ "@reach/rect" "0.16.0"
+ "@reach/utils" "0.16.0"
+ tabbable "^4.0.0"
+ tslib "^2.3.0"
+
+"@reach/portal@0.16.0":
+ version "0.16.0"
+ resolved "https://registry.yarnpkg.com/@reach/portal/-/portal-0.16.0.tgz#1544531d978b770770b718b2872b35652a11e7e3"
+ integrity sha512-vXJ0O9T+72HiSEWHPs2cx7YbSO7pQsTMhgqPc5aaddIYpo2clJx1PnYuS0lSNlVaDO0IxQhwYq43evXaXnmviw==
+ dependencies:
+ "@reach/utils" "0.16.0"
+ tslib "^2.3.0"
+
+"@reach/rect@0.16.0":
+ version "0.16.0"
+ resolved "https://registry.yarnpkg.com/@reach/rect/-/rect-0.16.0.tgz#78cf6acefe2e83d3957fa84f938f6e1fc5700f16"
+ integrity sha512-/qO9jQDzpOCdrSxVPR6l674mRHNTqfEjkaxZHluwJ/2qGUtYsA0GSZiF/+wX/yOWeBif1ycxJDa6HusAMJZC5Q==
+ dependencies:
+ "@reach/observe-rect" "1.2.0"
+ "@reach/utils" "0.16.0"
+ prop-types "^15.7.2"
+ tiny-warning "^1.0.3"
+ tslib "^2.3.0"
+
+"@reach/utils@0.16.0":
+ version "0.16.0"
+ resolved "https://registry.yarnpkg.com/@reach/utils/-/utils-0.16.0.tgz#5b0777cf16a7cab1ddd4728d5d02762df0ba84ce"
+ integrity sha512-PCggBet3qaQmwFNcmQ/GqHSefadAFyNCUekq9RrWoaU9hh/S4iaFgf2MBMdM47eQj5i/Bk0Mm07cP/XPFlkN+Q==
+ dependencies:
+ tiny-warning "^1.0.3"
+ tslib "^2.3.0"
+
+"@sgratzl/boxplots@^1.2.2":
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/@sgratzl/boxplots/-/boxplots-1.3.0.tgz#c9063d98e33a15f880cf4bd3531be71497e2a94e"
+ integrity sha512-2BRWv+WOH58pwzSgP50buoXgxQic+4auz3BF0wiIUXS8D3QGkdBNgsNdQO1754Tm/0uEwly0R3WaCiGnoYWcmA==
+
"@simbathesailor/use-what-changed@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz#7f82d78f92c8588b5fadd702065dde93bd781403"
@@ -3103,9 +3205,9 @@
"@types/lodash" "*"
"@types/lodash@*":
- version "4.14.176"
- resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.176.tgz#641150fc1cda36fbfa329de603bbb175d7ee20c0"
- integrity sha512-xZmuPTa3rlZoIbtDUyJKZQimJV3bxCmzMIO2c9Pz9afyDro6kr7R79GwcB6mRhuoPmV2p1Vb66WOJH7F886WKQ==
+ version "4.14.175"
+ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.175.tgz#b78dfa959192b01fae0ad90e166478769b215f45"
+ integrity sha512-XmdEOrKQ8a1Y/yxQFOMbC47G/V2VDO1GvMRnl4O75M4GW/abC5tnfzadQYkqEveqRM1dEJGFFegfPNA2vvx2iw==
"@types/lodash@^4.14.175":
version "4.14.177"
@@ -3211,6 +3313,13 @@
dependencies:
"@types/react" "*"
+"@types/react-table@^7.7.6":
+ version "7.7.6"
+ resolved "https://registry.yarnpkg.com/@types/react-table/-/react-table-7.7.6.tgz#4899ccc46b4a1de08cc1daf120842e37e112e51e"
+ integrity sha512-ZMFHh1sG5AGDmhVRpz9mgGByGmBFAqnZ7QnyqGa5iAlKtcSC3vb/gul47lM0kJ1uvlawc+qN5k+++pe+GBdJ+g==
+ dependencies:
+ "@types/react" "*"
+
"@types/react-transition-group@^4.4.0":
version "4.4.4"
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.4.tgz#acd4cceaa2be6b757db61ed7b432e103242d163e"
@@ -6576,6 +6685,11 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0"
+date-fns@^2.21.3:
+ version "2.27.0"
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.27.0.tgz#e1ff3c3ddbbab8a2eaadbb6106be2929a5a2d92b"
+ integrity sha512-sj+J0Mo2p2X1e306MHq282WS4/A8Pz/95GIFcsPNMPMZVI3EUrAdSv90al1k+p74WGLCruMXk23bfEDZa71X9Q==
+
dateformat@~1.0.12:
version "1.0.12"
resolved "https://registry.npmjs.org/dateformat/-/dateformat-1.0.12.tgz"
@@ -12381,16 +12495,11 @@ mkdirp@^1.0.3, mkdirp@^1.0.4, mkdirp@~1.0.3, mkdirp@~1.0.4:
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
-moment@^2.10.2, moment@^2.29.1:
+moment@^2.10.2, moment@^2.16.0, moment@^2.21.0, moment@^2.29.1:
version "2.29.1"
- resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
-moment@^2.16.0:
- version "2.27.0"
- resolved "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz"
- integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ==
-
move-concurrently@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz"
@@ -14665,6 +14774,11 @@ react-syntax-highlighter@^13.5.3:
prismjs "^1.21.0"
refractor "^3.1.0"
+react-table@^7.7.0:
+ version "7.7.0"
+ resolved "https://registry.yarnpkg.com/react-table/-/react-table-7.7.0.tgz#e2ce14d7fe3a559f7444e9ecfe8231ea8373f912"
+ integrity sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA==
+
react-test-renderer@^17.0.2:
version "17.0.2"
resolved "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz"
@@ -16465,6 +16579,11 @@ synchronous-promise@^2.0.15:
resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.15.tgz#07ca1822b9de0001f5ff73595f3d08c4f720eb8e"
integrity sha512-k8uzYIkIVwmT+TcglpdN50pS2y1BDcUnBPK9iJeGu0Pl1lOI8pD6wtzgw91Pjpe+RxtTncw32tLxs/R0yNL2Mg==
+tabbable@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-4.0.0.tgz#5bff1d1135df1482cf0f0206434f15eadbeb9261"
+ integrity sha512-H1XoH1URcBOa/rZZWxLxHCtOdVUEev+9vo5YdYhC9tCY4wnybX+VQrCYuy9ubkg69fCBxCONJOSLGfw0DWMffQ==
+
table@^3.7.8:
version "3.8.3"
resolved "https://registry.npmjs.org/table/-/table-3.8.3.tgz"
@@ -16661,7 +16780,7 @@ timsort@^0.3.0:
resolved "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz"
integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
-tiny-warning@^1.0.2:
+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"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==