refactor(containers): migrate view to react [EE-2212] (#6577)

Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
pull/7382/head
Chaim Lev-Ari 2022-08-11 07:33:29 +03:00 committed by GitHub
parent 5ee570e075
commit bed4257194
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 1616 additions and 875 deletions

View File

@ -0,0 +1,86 @@
package containers
import (
"net/http"
"strings"
containertypes "github.com/docker/docker/api/types/container"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portaineree "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/middlewares"
"golang.org/x/exp/slices"
)
type containerGpusResponse struct {
Gpus string `json:"gpus"`
}
// @id dockerContainerGpusInspect
// @summary Fetch container gpus data
// @description
// @description **Access policy**:
// @tags docker
// @security jwt
// @accept json
// @produce json
// @param environmentId path int true "Environment identifier"
// @param containerId path int true "Container identifier"
// @success 200 {object} containerGpusResponse "Success"
// @failure 404 "Environment or container not found"
// @failure 400 "Bad request"
// @failure 500 "Internal server error"
// @router /docker/{environmentId}/containers/{containerId}/gpus [get]
func (handler *Handler) containerGpusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
containerId, err := request.RetrieveRouteVariableValue(r, "containerId")
if err != nil {
return httperror.BadRequest("Invalid container identifier route variable", err)
}
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
return httperror.NotFound("Unable to find an environment on request context", err)
}
agentTargetHeader := r.Header.Get(portaineree.PortainerAgentTargetHeader)
cli, err := handler.dockerClientFactory.CreateClient(endpoint, agentTargetHeader, nil)
if err != nil {
return httperror.InternalServerError("Unable to connect to the Docker daemon", err)
}
container, err := cli.ContainerInspect(r.Context(), containerId)
if err != nil {
return httperror.NotFound("Unable to find the container", err)
}
if container.HostConfig == nil {
return httperror.NotFound("Unable to find the container host config", err)
}
gpuOptionsIndex := slices.IndexFunc(container.HostConfig.DeviceRequests, func(opt containertypes.DeviceRequest) bool {
if opt.Driver == "nvidia" {
return true
}
if len(opt.Capabilities) == 0 || len(opt.Capabilities[0]) == 0 {
return false
}
return opt.Capabilities[0][0] == "gpu"
})
if gpuOptionsIndex == -1 {
return response.JSON(w, containerGpusResponse{Gpus: "none"})
}
gpuOptions := container.HostConfig.DeviceRequests[gpuOptionsIndex]
gpu := "all"
if gpuOptions.Count != -1 {
gpu = "id:" + strings.Join(gpuOptions.DeviceIDs, ",")
}
return response.JSON(w, containerGpusResponse{Gpus: gpu})
}

View File

@ -0,0 +1,31 @@
package containers
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/http/security"
)
type Handler struct {
*mux.Router
dockerClientFactory *docker.ClientFactory
}
// NewHandler creates a handler to process non-proxied requests to docker APIs directly.
func NewHandler(routePrefix string, bouncer *security.RequestBouncer, dockerClientFactory *docker.ClientFactory) *Handler {
h := &Handler{
Router: mux.NewRouter(),
dockerClientFactory: dockerClientFactory,
}
router := h.PathPrefix(routePrefix).Subrouter()
router.Use(bouncer.AuthenticatedAccess)
router.Handle("/{containerId}/gpus", httperror.LoggerHandler(h.containerGpusInspect)).Methods(http.MethodGet)
return h
}

View File

@ -0,0 +1,63 @@
package docker
import (
"errors"
"net/http"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/handler/docker/containers"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
)
// Handler is the HTTP handler which will natively deal with to external environments(endpoints).
type Handler struct {
*mux.Router
requestBouncer *security.RequestBouncer
dataStore dataservices.DataStore
dockerClientFactory *docker.ClientFactory
authorizationService *authorization.Service
}
// NewHandler creates a handler to process non-proxied requests to docker APIs directly.
func NewHandler(bouncer *security.RequestBouncer, authorizationService *authorization.Service, dataStore dataservices.DataStore, dockerClientFactory *docker.ClientFactory) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
authorizationService: authorizationService,
dataStore: dataStore,
dockerClientFactory: dockerClientFactory,
}
// endpoints
endpointRouter := h.PathPrefix("/{id}").Subrouter()
endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
endpointRouter.Use(dockerOnlyMiddleware)
containersHandler := containers.NewHandler("/{id}/containers", bouncer, dockerClientFactory)
endpointRouter.PathPrefix("/containers").Handler(containersHandler)
return h
}
func dockerOnlyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
endpoint, err := middlewares.FetchEndpoint(request)
if err != nil {
httperror.WriteError(rw, http.StatusInternalServerError, "Unable to find an environment on request context", err)
return
}
if !endpointutils.IsDockerEndpoint(endpoint) {
errMessage := "environment is not a docker environment"
httperror.WriteError(rw, http.StatusBadRequest, errMessage, errors.New(errMessage))
return
}
next.ServeHTTP(rw, request)
})
}

View File

@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/api/http/handler/auth"
"github.com/portainer/portainer/api/http/handler/backup"
"github.com/portainer/portainer/api/http/handler/customtemplates"
"github.com/portainer/portainer/api/http/handler/docker"
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks"
@ -45,6 +46,7 @@ type Handler struct {
AuthHandler *auth.Handler
BackupHandler *backup.Handler
CustomTemplatesHandler *customtemplates.Handler
DockerHandler *docker.Handler
EdgeGroupsHandler *edgegroups.Handler
EdgeJobsHandler *edgejobs.Handler
EdgeStacksHandler *edgestacks.Handler

View File

@ -21,6 +21,7 @@ import (
"github.com/portainer/portainer/api/http/handler/auth"
"github.com/portainer/portainer/api/http/handler/backup"
"github.com/portainer/portainer/api/http/handler/customtemplates"
dockerhandler "github.com/portainer/portainer/api/http/handler/docker"
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/handler/edgejobs"
"github.com/portainer/portainer/api/http/handler/edgestacks"
@ -184,6 +185,8 @@ func (server *Server) Start() error {
var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.JWTService, server.KubeClusterAccessService, server.KubernetesClientFactory)
var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory)
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), adminMonitor.WasInstanceDisabled)
var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService)
@ -275,6 +278,7 @@ func (server *Server) Start() error {
AuthHandler: authHandler,
BackupHandler: backupHandler,
CustomTemplatesHandler: customTemplatesHandler,
DockerHandler: dockerHandler,
EdgeGroupsHandler: edgeGroupsHandler,
EdgeJobsHandler: edgeJobsHandler,
EdgeStacksHandler: edgeStacksHandler,

View File

@ -96,94 +96,6 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
},
};
var containers = {
name: 'docker.containers',
url: '/containers',
views: {
'content@': {
templateUrl: './views/containers/containers.html',
controller: 'ContainersController',
},
},
};
var container = {
name: 'docker.containers.container',
url: '/:id?nodeName',
views: {
'content@': {
templateUrl: './views/containers/edit/container.html',
controller: 'ContainerController',
},
},
};
var containerAttachConsole = {
name: 'docker.containers.container.attach',
url: '/attach',
views: {
'content@': {
templateUrl: './views/containers/console/attach.html',
controller: 'ContainerConsoleController',
},
},
};
var containerExecConsole = {
name: 'docker.containers.container.exec',
url: '/exec',
views: {
'content@': {
templateUrl: './views/containers/console/exec.html',
controller: 'ContainerConsoleController',
},
},
};
var containerCreation = {
name: 'docker.containers.new',
url: '/new?nodeName&from',
views: {
'content@': {
templateUrl: './views/containers/create/createcontainer.html',
controller: 'CreateContainerController',
},
},
};
var containerInspect = {
name: 'docker.containers.container.inspect',
url: '/inspect',
views: {
'content@': {
templateUrl: './views/containers/inspect/containerinspect.html',
controller: 'ContainerInspectController',
},
},
};
var containerLogs = {
name: 'docker.containers.container.logs',
url: '/logs',
views: {
'content@': {
templateUrl: './views/containers/logs/containerlogs.html',
controller: 'ContainerLogsController',
},
},
};
var containerStats = {
name: 'docker.containers.container.stats',
url: '/stats',
views: {
'content@': {
templateUrl: './views/containers/stats/containerstats.html',
controller: 'ContainerStatsController',
},
},
};
const customTemplates = {
name: 'docker.templates.custom',
url: '/custom',
@ -613,14 +525,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
$stateRegistryProvider.register(configs);
$stateRegistryProvider.register(config);
$stateRegistryProvider.register(configCreation);
$stateRegistryProvider.register(containers);
$stateRegistryProvider.register(container);
$stateRegistryProvider.register(containerExecConsole);
$stateRegistryProvider.register(containerAttachConsole);
$stateRegistryProvider.register(containerCreation);
$stateRegistryProvider.register(containerInspect);
$stateRegistryProvider.register(containerLogs);
$stateRegistryProvider.register(containerStats);
$stateRegistryProvider.register(customTemplates);
$stateRegistryProvider.register(customTemplatesNew);
$stateRegistryProvider.register(customTemplatesEdit);

View File

@ -1,24 +1,13 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { ContainersDatatableContainer } from '@/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableContainer';
import { StackContainersDatatable } from '@/react/docker/stacks/ItemView/StackContainersDatatable';
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
import { TemplateListDropdownAngular } from '@/react/docker/app-templates/TemplateListDropdown';
import { TemplateListSortAngular } from '@/react/docker/app-templates/TemplateListSort';
export const componentsModule = angular
.module('portainer.docker.react.components', [])
.component(
'containersDatatable',
r2a(ContainersDatatableContainer, [
'endpoint',
'isAddActionVisible',
'dataset',
'onRefresh',
'isHostColumnVisible',
'tableKey',
])
)
.component(
'containerQuickActions',
r2a(ContainerQuickActions, [
@ -30,4 +19,8 @@ export const componentsModule = angular
])
)
.component('templateListDropdown', TemplateListDropdownAngular)
.component('templateListSort', TemplateListSortAngular).name;
.component('templateListSort', TemplateListSortAngular)
.component(
'stackContainersDatatable',
r2a(StackContainersDatatable, ['environment', 'stackName'])
).name;

View File

@ -0,0 +1,101 @@
import { StateRegistry } from '@uirouter/angularjs';
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { ListView } from '@/react/docker/containers/ListView';
export const containersModule = angular
.module('portainer.docker.containers', [])
.component('containersView', r2a(ListView, ['endpoint']))
.config(config).name;
/* @ngInject */
function config($stateRegistryProvider: StateRegistry) {
$stateRegistryProvider.register({
name: 'docker.containers',
url: '/containers',
views: {
'content@': {
component: 'containersView',
},
},
});
$stateRegistryProvider.register({
name: 'docker.containers.container',
url: '/:id?nodeName',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/edit/container.html',
controller: 'ContainerController',
},
},
});
$stateRegistryProvider.register({
name: 'docker.containers.container.attach',
url: '/attach',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/console/attach.html',
controller: 'ContainerConsoleController',
},
},
});
$stateRegistryProvider.register({
name: 'docker.containers.container.exec',
url: '/exec',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/console/exec.html',
controller: 'ContainerConsoleController',
},
},
});
$stateRegistryProvider.register({
name: 'docker.containers.new',
url: '/new?nodeName&from',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/create/createcontainer.html',
controller: 'CreateContainerController',
},
},
});
$stateRegistryProvider.register({
name: 'docker.containers.container.inspect',
url: '/inspect',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/inspect/containerinspect.html',
controller: 'ContainerInspectController',
},
},
});
$stateRegistryProvider.register({
name: 'docker.containers.container.logs',
url: '/logs',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/logs/containerlogs.html',
controller: 'ContainerLogsController',
},
},
});
$stateRegistryProvider.register({
name: 'docker.containers.container.stats',
url: '/stats',
views: {
'content@': {
templateUrl: '~@/docker/views/containers/stats/containerstats.html',
controller: 'ContainerStatsController',
},
},
});
}

View File

@ -1,13 +1,15 @@
import angular from 'angular';
import { Gpu } from 'Docker/react/views/gpu';
import { ItemView } from '@/react/docker/networks/ItemView';
import { ItemView as NetworksItemView } from '@/react/docker/networks/ItemView';
import { r2a } from '@/react-tools/react2angular';
import { containersModule } from './containers';
export const viewsModule = angular
.module('portainer.docker.react.views', [])
.module('portainer.docker.react.views', [containersModule])
.component(
'gpu',
r2a(Gpu, ['values', 'onChange', 'gpus', 'usedGpus', 'usedAllGpus'])
)
.component('networkDetailsView', r2a(ItemView, [])).name;
.component('networkDetailsView', r2a(NetworksItemView, [])).name;

View File

@ -36,20 +36,6 @@ export async function getInfo(environmentId: EnvironmentId) {
}
}
function buildUrl(
environmentId: EnvironmentId,
action: string,
subAction = ''
) {
let url = `/endpoints/${environmentId}/docker/${action}`;
if (subAction) {
url += `/${subAction}`;
}
return url;
}
export function useInfo<TSelect = InfoResponse>(
environmentId: EnvironmentId,
select?: (info: InfoResponse) => TSelect
@ -74,3 +60,17 @@ export function useVersion<TSelect = VersionResponse>(
}
);
}
function buildUrl(
environmentId: EnvironmentId,
action: string,
subAction = ''
) {
let url = `/endpoints/${environmentId}/docker/${action}`;
if (subAction) {
url += `/${subAction}`;
}
return url;
}

View File

@ -1,15 +0,0 @@
<page-header title="'Container list'" breadcrumbs="['Containers']" reload="true"> </page-header>
<information-panel-offline ng-if="offlineMode"></information-panel-offline>
<div class="row">
<div class="col-sm-12" ng-if="containers">
<containers-datatable
endpoint="endpoint"
dataset="containers"
is-host-column-visible="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
is-add-action-visible="true"
on-refresh="(getContainers)"
></containers-datatable>
</div>
</div>

View File

@ -1,60 +0,0 @@
angular.module('portainer.docker').controller('ContainersController', ContainersController);
import _ from 'lodash';
/* @ngInject */
function ContainersController($scope, ContainerService, Notifications, endpoint) {
$scope.offlineMode = endpoint.Status !== 1;
$scope.endpoint = endpoint;
$scope.getContainers = getContainers;
function getContainers() {
$scope.containers = null;
$scope.containers_t = null;
ContainerService.containers(1)
.then(function success(data) {
$scope.containers_t = data;
if ($scope.containers_t.length === 0) {
$scope.containers = $scope.containers_t;
return;
}
for (let item of $scope.containers_t) {
ContainerService.container(item.Id).then(function success(data) {
var Id = data.Id;
for (var i = 0; i < $scope.containers_t.length; i++) {
if (Id == $scope.containers_t[i].Id) {
const gpuOptions = _.find(data.HostConfig.DeviceRequests, function (o) {
return o.Driver === 'nvidia' || o.Capabilities[0][0] === 'gpu';
});
if (!gpuOptions) {
$scope.containers_t[i]['Gpus'] = 'none';
} else {
let gpuStr = 'all';
if (gpuOptions.Count !== -1) {
gpuStr = `id:${_.join(gpuOptions.DeviceIDs, ',')}`;
}
$scope.containers_t[i]['Gpus'] = `${gpuStr}`;
}
}
}
for (let item of $scope.containers_t) {
if (!Object.keys(item).includes('Gpus')) {
return;
}
}
$scope.containers = $scope.containers_t;
});
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve containers');
$scope.containers = [];
});
}
function initView() {
getContainers();
}
initView();
}

View File

@ -1,11 +1,8 @@
import {
createContext,
useContext,
useMemo,
useReducer,
PropsWithChildren,
} from 'react';
import { EnvironmentId } from 'Portainer/environments/types';
import { PropsWithChildren, useMemo, useReducer } from 'react';
import { EnvironmentId } from '@/portainer/environments/types';
import { createRowContext } from '@@/datatables/RowContext';
interface RowContextState {
environmentId: EnvironmentId;
@ -13,31 +10,29 @@ interface RowContextState {
toggleIsLoading(): void;
}
const RowContext = createContext<RowContextState | null>(null);
const { RowProvider: InternalProvider, useRowContext } =
createRowContext<RowContextState>();
export interface RowProviderProps {
export { useRowContext };
interface Props {
environmentId: EnvironmentId;
}
export function RowProvider({
environmentId,
children,
}: PropsWithChildren<RowProviderProps>) {
}: PropsWithChildren<Props>) {
const [isLoading, toggleIsLoading] = useReducer((state) => !state, false);
const state = useMemo(
() => ({ isLoading, toggleIsLoading, environmentId }),
[isLoading, toggleIsLoading, environmentId]
const context = useMemo(
() => ({
isLoading,
toggleIsLoading,
environmentId,
}),
[environmentId, isLoading]
);
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
}
export function useRowContext() {
const context = useContext(RowContext);
if (!context) {
throw new Error('should be nested under RowProvider');
}
return context;
return <InternalProvider context={context}>{children}</InternalProvider>;
}

View File

@ -197,8 +197,7 @@ export function EdgeDevicesDatatable({
return (
<RowProvider
key={key}
isOpenAmtEnabled={isOpenAmtEnabled}
groupName={group[0]?.Name}
context={{ isOpenAmtEnabled, groupName: group[0]?.Name }}
>
<TableRow<Environment>
cells={row.cells}

View File

@ -1,35 +1,10 @@
import { createContext, useContext, useMemo, PropsWithChildren } from 'react';
import { createRowContext } from '@@/datatables/RowContext';
interface RowContextState {
isOpenAmtEnabled: boolean;
groupName?: string;
}
const RowContext = createContext<RowContextState | null>(null);
const { RowProvider, useRowContext } = createRowContext<RowContextState>();
export interface RowProviderProps {
groupName?: string;
isOpenAmtEnabled: boolean;
}
export function RowProvider({
groupName,
isOpenAmtEnabled,
children,
}: PropsWithChildren<RowProviderProps>) {
const state = useMemo(
() => ({ groupName, isOpenAmtEnabled }),
[groupName, isOpenAmtEnabled]
);
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
}
export function useRowContext() {
const context = useContext(RowContext);
if (!context) {
throw new Error('should be nested under RowProvider');
}
return context;
}
export { RowProvider, useRowContext };

View File

@ -3,7 +3,7 @@ import {
RefreshableTableSettings,
SettableColumnsTableSettings,
SortableTableSettings,
} from '@@/datatables/types';
} from '@@/datatables/types-old';
export interface Pagination {
pageLimit: number;

View File

@ -1,7 +1,7 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types';
} from '@@/datatables/types-old';
export interface TableSettings
extends SortableTableSettings,

View File

@ -1,27 +0,0 @@
import { createContext, ReactNode, useContext } from 'react';
import type { Environment } from './types';
const EnvironmentContext = createContext<Environment | null>(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 (
<EnvironmentContext.Provider value={environment}>
{children}
</EnvironmentContext.Provider>
);
}

View File

@ -0,0 +1,8 @@
import { useEnvironment } from '../environments/queries/useEnvironment';
import { useEnvironmentId } from './useEnvironmentId';
export function useCurrentEnvironment() {
const id = useEnvironmentId();
return useEnvironment(id);
}

View File

@ -2,7 +2,7 @@ import { useState, useCallback, useMemo } from 'react';
const localStoragePrefix = 'portainer';
function keyBuilder(key: string) {
export function keyBuilder(key: string) {
return `${localStoragePrefix}.${key}`;
}

View File

@ -20,7 +20,7 @@ import {
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types';
} from '@@/datatables/types-old';
import { useFDOProfiles } from './useFDOProfiles';
import { useColumns } from './columns';

View File

@ -132,10 +132,10 @@
<uib-tab-heading> <i class="fa fa-pencil-alt space-right" aria-hidden="true"></i> Editor </uib-tab-heading>
<form class="form-horizontal" ng-if="state.showEditorTab" style="margin-top: 10px" name="stackUpdateForm">
<div class="form-group">
<span class="col-sm-12 text-muted small" style="margin-bottom: 7px" ng-if="stackType == 2 && composeSyntaxMaxVersion == 2">
<span class="col-sm-12 text-muted small" style="margin-bottom: 7px" ng-if="stackType == STACK_TYPES.DockerCompose && composeSyntaxMaxVersion == 2">
This stack will be deployed using the equivalent of <code>docker compose</code>. Only Compose file format version <b>2</b> is supported at the moment.
</span>
<span class="col-sm-12 text-muted small" style="margin-bottom: 7px" ng-if="stackType == 2 && composeSyntaxMaxVersion > 2">
<span class="col-sm-12 text-muted small" style="margin-bottom: 7px" ng-if="stackType == STACK_TYPES.DockerCompose && composeSyntaxMaxVersion > 2">
This stack will be deployed using <code>docker compose</code>.
</span>
<span class="col-sm-12 text-muted small">
@ -222,11 +222,11 @@
</div>
</div>
<div class="row" ng-if="containers && (!orphaned || orphanedRunning)">
<div class="col-sm-12">
<containers-datatable dataset="containers" endpoint="endpoint" table-key="stack-containers"></containers-datatable>
</div>
</div>
<stack-containers-datatable
ng-if="stackType !== STACK_TYPES.DockerSwarm && (!orphaned || orphanedRunning)"
stack-name="stackName"
environment="endpoint"
></stack-containers-datatable>
<div class="row" ng-if="services && (!orphaned || orphanedRunning)">
<div class="col-sm-12">

View File

@ -2,6 +2,7 @@ import { ResourceControlType } from '@/portainer/access-control/types';
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
import { FeatureId } from 'Portainer/feature-flags/enums';
import { getEnvironments } from '@/portainer/environments/environment.service';
import { StackStatus, StackType } from '@/react/docker/stacks/types';
angular.module('portainer.app').controller('StackController', [
'$async',
@ -54,6 +55,8 @@ angular.module('portainer.app').controller('StackController', [
ContainerHelper,
endpoint
) {
$scope.STACK_TYPES = StackType;
$scope.resourceType = ResourceControlType.Stack;
$scope.onUpdateResourceControlSuccess = function () {
@ -363,19 +366,15 @@ angular.module('portainer.app').controller('StackController', [
});
})
.then(function success(data) {
const isSwarm = $scope.stack.Type === 1;
const isSwarm = $scope.stack.Type === StackType.DockerSwarm;
$scope.stackFileContent = data.stackFile;
// workaround for missing status, if stack has resources, set the status to 1 (active), otherwise to 2 (inactive) (https://github.com/portainer/portainer/issues/4422)
if (!$scope.stack.Status) {
$scope.stack.Status = data.resources && ((isSwarm && data.resources.services.length) || data.resources.containers.length) ? 1 : 2;
}
if ($scope.stack.Status === 1) {
if (isSwarm) {
assignSwarmStackResources(data.resources, agentProxy);
} else {
assignComposeStackResources(data.resources);
}
if (isSwarm && $scope.stack.Status === StackStatus.Active) {
assignSwarmStackResources(data.resources, agentProxy);
}
$scope.state.yamlError = StackHelper.validateYAML($scope.stackFileContent, $scope.containerNames);
@ -431,21 +430,15 @@ angular.module('portainer.app').controller('StackController', [
});
}
function assignComposeStackResources(resources) {
$scope.containers = resources.containers;
}
function loadExternalStack(name) {
var stackType = $transition$.params().type;
if (!stackType || (stackType !== '1' && stackType !== '2')) {
const stackType = $scope.stackType;
if (!stackType || (stackType !== StackType.DockerSwarm && stackType !== StackType.DockerCompose)) {
Notifications.error('Failure', null, 'Invalid type URL parameter.');
return;
}
if (stackType === '1') {
if (stackType === StackType.DockerSwarm) {
loadExternalSwarmStack(name);
} else {
loadExternalComposeStack(name);
}
}
@ -461,16 +454,6 @@ angular.module('portainer.app').controller('StackController', [
});
}
function loadExternalComposeStack(name) {
retrieveComposeStackResources(name)
.then(function success(data) {
assignComposeStackResources(data);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve stack details');
});
}
this.uiCanExit = async function () {
if ($scope.stackFileContent && $scope.state.isEditorDirty) {
return ModalService.confirmWebEditorDiscard();
@ -515,6 +498,7 @@ angular.module('portainer.app').controller('StackController', [
$scope.composeSyntaxMaxVersion = endpoint.ComposeSyntaxMaxVersion;
$scope.stackType = $transition$.params().type;
$scope.editorReadOnly = !Authentication.hasAuthorizations(['PortainerStackUpdate']);
}
initView();

View File

@ -1,7 +1,7 @@
import {
PaginationTableSettings,
SortableTableSettings,
} from '@@/datatables/types';
} from '@@/datatables/types-old';
export interface TableSettings
extends PaginationTableSettings,

View File

@ -0,0 +1,238 @@
import {
useTable,
useFilters,
useGlobalFilter,
useSortBy,
usePagination,
Column,
Row,
TableInstance,
TableState,
} from 'react-table';
import { ReactNode } from 'react';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { PaginationControls } from '@@/PaginationControls';
import { Table } from './Table';
import { multiple } from './filter-types';
import { SearchBar, useSearchBarState } from './SearchBar';
import { SelectedRowsCount } from './SelectedRowsCount';
import { TableSettingsProvider } from './useZustandTableSettings';
import { useRowSelect } from './useRowSelect';
import { PaginationTableSettings, SortableTableSettings } from './types';
interface DefaultTableSettings
extends SortableTableSettings,
PaginationTableSettings {}
interface TitleOptionsVisible {
title: string;
icon?: string;
hide?: never;
}
type TitleOptions = TitleOptionsVisible | { hide: true };
interface Props<
D extends Record<string, unknown>,
TSettings extends DefaultTableSettings
> {
dataset: D[];
storageKey: string;
columns: readonly Column<D>[];
renderTableSettings?(instance: TableInstance<D>): ReactNode;
renderTableActions?(selectedRows: D[]): ReactNode;
settingsStore: TSettings;
disableSelect?: boolean;
getRowId?(row: D): string;
isRowSelectable?(row: Row<D>): boolean;
emptyContentLabel?: string;
titleOptions: TitleOptions;
initialTableState?: Partial<TableState<D>>;
isLoading?: boolean;
totalCount?: number;
}
export function Datatable<
D extends Record<string, unknown>,
TSettings extends DefaultTableSettings
>({
columns,
dataset,
storageKey,
renderTableSettings,
renderTableActions,
settingsStore,
disableSelect,
getRowId = defaultGetRowId,
isRowSelectable = () => true,
titleOptions,
emptyContentLabel,
initialTableState = {},
isLoading,
totalCount = dataset.length,
}: Props<D, TSettings>) {
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
const tableInstance = useTable<D>(
{
defaultCanFilter: false,
columns,
data: dataset,
filterTypes: { multiple },
initialState: {
pageSize: settingsStore.pageSize || 10,
sortBy: [settingsStore.sortBy],
globalFilter: searchBarValue,
...initialTableState,
},
isRowSelectable,
autoResetSelectedRows: false,
getRowId,
stateReducer: (newState, action) => {
switch (action.type) {
case 'setGlobalFilter':
setSearchBarValue(action.filterValue);
break;
case 'toggleSortBy':
settingsStore.setSortBy(action.columnId, action.desc);
break;
case 'setPageSize':
settingsStore.setPageSize(action.pageSize);
break;
default:
break;
}
return newState;
},
},
useFilters,
useGlobalFilter,
useSortBy,
usePagination,
useRowSelect,
!disableSelect ? useRowSelectColumn : emptyPlugin
);
const {
selectedFlatRows,
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
gotoPage,
setPageSize,
setGlobalFilter,
state: { pageIndex, pageSize },
} = tableInstance;
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
const selectedItems = selectedFlatRows.map((row) => row.original);
return (
<div className="row">
<div className="col-sm-12">
<TableSettingsProvider settings={settingsStore}>
<Table.Container>
{isTitleVisible(titleOptions) && (
<Table.Title label={titleOptions.title} icon={titleOptions.icon}>
<SearchBar value={searchBarValue} onChange={setGlobalFilter} />
{renderTableActions && (
<Table.Actions>
{renderTableActions(selectedItems)}
</Table.Actions>
)}
<Table.TitleActions>
{!!renderTableSettings && renderTableSettings(tableInstance)}
</Table.TitleActions>
</Table.Title>
)}
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<Table.HeaderRow<D>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
<Table.Content<D>
rows={page}
isLoading={isLoading}
prepareRow={prepareRow}
emptyContent={emptyContentLabel}
renderRow={(row, { key, className, role, style }) => (
<Table.Row<D>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
)}
/>
</tbody>
</Table>
<Table.Footer>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={totalCount}
onPageLimitChange={setPageSize}
/>
</Table.Footer>
</Table.Container>
</TableSettingsProvider>
</div>
</div>
);
}
function isTitleVisible(
titleSettings: TitleOptions
): titleSettings is TitleOptionsVisible {
return !titleSettings.hide;
}
function defaultGetRowId<D extends Record<string, unknown>>(row: D): string {
if (row.id && (typeof row.id === 'string' || typeof row.id === 'number')) {
return row.id.toString();
}
if (row.Id && (typeof row.Id === 'string' || typeof row.Id === 'number')) {
return row.Id.toString();
}
if (row.ID && (typeof row.ID === 'string' || typeof row.ID === 'number')) {
return row.ID.toString();
}
return '';
}
function emptyPlugin() {}
emptyPlugin.pluginName = 'emptyPlugin';

View File

@ -1,9 +1,14 @@
import {
SettableQuickActionsTableSettings,
QuickAction,
} from '@/react/docker/containers/ListView/ContainersDatatable/types';
import { Checkbox } from '@@/form-components/Checkbox';
import { useTableSettings } from './useTableSettings';
import { useTableSettings } from './useZustandTableSettings';
export interface Action {
id: string;
id: QuickAction;
label: string;
}
@ -11,13 +16,9 @@ interface Props {
actions: Action[];
}
export interface QuickActionsSettingsType {
hiddenQuickActions: string[];
}
export function QuickActionsSettings({ actions }: Props) {
const { settings, setTableSettings } =
useTableSettings<QuickActionsSettingsType>();
const { settings } =
useTableSettings<SettableQuickActionsTableSettings<QuickAction>>();
return (
<>
@ -33,16 +34,17 @@ export function QuickActionsSettings({ actions }: Props) {
</>
);
function toggleAction(key: string, value: boolean) {
setTableSettings(({ hiddenQuickActions = [], ...settings }) => ({
...settings,
hiddenQuickActions: value
? hiddenQuickActions.filter((id) => id !== key)
: [...hiddenQuickActions, key],
}));
function toggleAction(key: QuickAction, visible: boolean) {
if (!visible) {
settings.setHiddenQuickActions([...settings.hiddenQuickActions, key]);
} else {
settings.setHiddenQuickActions(
settings.hiddenQuickActions.filter((action) => action !== key)
);
}
}
}
export function buildAction(id: string, label: string): Action {
export function buildAction(id: QuickAction, label: string): Action {
return { id, label };
}

View File

@ -0,0 +1,23 @@
import { createContext, PropsWithChildren, useContext } from 'react';
export function createRowContext<TContext>() {
const Context = createContext<TContext | null>(null);
return { RowProvider, useRowContext };
function RowProvider({
children,
context,
}: PropsWithChildren<{ context: TContext }>) {
return <Context.Provider value={context}>{children}</Context.Provider>;
}
function useRowContext() {
const context = useContext(Context);
if (!context) {
throw new Error('should be nested under RowProvider');
}
return context;
}
}

View File

@ -2,9 +2,18 @@ import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { TableProps } from 'react-table';
import { useTableContext } from './TableContainer';
import { useTableContext, TableContainer } from './TableContainer';
import { TableActions } from './TableActions';
import { TableTitleActions } from './TableTitleActions';
import { TableHeaderCell } from './TableHeaderCell';
import { TableSettingsMenu } from './TableSettingsMenu';
import { TableTitle } from './TableTitle';
import { TableHeaderRow } from './TableHeaderRow';
import { TableRow } from './TableRow';
import { TableContent } from './TableContent';
import { TableFooter } from './TableFooter';
export function Table({
function MainComponent({
children,
className,
role,
@ -27,3 +36,30 @@ export function Table({
</div>
);
}
interface SubComponents {
Container: typeof TableContainer;
Actions: typeof TableActions;
TitleActions: typeof TableTitleActions;
HeaderCell: typeof TableHeaderCell;
SettingsMenu: typeof TableSettingsMenu;
Title: typeof TableTitle;
Row: typeof TableRow;
HeaderRow: typeof TableHeaderRow;
Content: typeof TableContent;
Footer: typeof TableFooter;
}
export const Table: typeof MainComponent & SubComponents =
MainComponent as typeof MainComponent & SubComponents;
Table.Actions = TableActions;
Table.TitleActions = TableTitleActions;
Table.Container = TableContainer;
Table.HeaderCell = TableHeaderCell;
Table.SettingsMenu = TableSettingsMenu;
Table.Title = TableTitle;
Table.Row = TableRow;
Table.HeaderRow = TableHeaderRow;
Table.Content = TableContent;
Table.Footer = TableFooter;

View File

@ -3,7 +3,7 @@ import { Cell, TableRowProps } from 'react-table';
import { useTableContext } from './TableContainer';
interface Props<D extends Record<string, unknown> = Record<string, unknown>>
extends TableRowProps {
extends Omit<TableRowProps, 'key'> {
cells: Cell<D>[];
}

View File

@ -1,10 +1,12 @@
import { PropsWithChildren } from 'react';
import { ComponentType, PropsWithChildren, ReactNode } from 'react';
import { Icon, IconProps } from '@@/Icon';
import { Icon } from '@@/Icon';
import { useTableContext } from './TableContainer';
interface Props extends IconProps {
interface Props {
icon?: ReactNode | ComponentType<unknown>;
featherIcon?: boolean;
label: string;
}
@ -19,13 +21,15 @@ export function TableTitle({
return (
<div className="toolBar">
<div className="toolBarTitle">
<div className="widget-icon">
<Icon
icon={icon}
feather={featherIcon}
className="space-right feather"
/>
</div>
{icon && (
<div className="widget-icon">
<Icon
icon={icon}
feather={featherIcon}
className="space-right feather"
/>
</div>
)}
{label}
</div>

View File

@ -0,0 +1,13 @@
export { Datatable } from './Datatable';
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';
export { TableContent } from './TableContent';
export { TableFooter } from './TableFooter';

View File

@ -1,50 +0,0 @@
import { Table as MainComponent } from './Table';
import { TableActions } from './TableActions';
import { TableTitleActions } from './TableTitleActions';
import { TableHeaderCell } from './TableHeaderCell';
import { TableSettingsMenu } from './TableSettingsMenu';
import { TableTitle } from './TableTitle';
import { TableContainer } from './TableContainer';
import { TableHeaderRow } from './TableHeaderRow';
import { TableRow } from './TableRow';
import { TableContent } from './TableContent';
import { TableFooter } from './TableFooter';
interface SubComponents {
Container: typeof TableContainer;
Actions: typeof TableActions;
TitleActions: typeof TableTitleActions;
HeaderCell: typeof TableHeaderCell;
SettingsMenu: typeof TableSettingsMenu;
Title: typeof TableTitle;
Row: typeof TableRow;
HeaderRow: typeof TableHeaderRow;
Content: typeof TableContent;
Footer: typeof TableFooter;
}
const Table: typeof MainComponent & SubComponents =
MainComponent as typeof MainComponent & SubComponents;
Table.Actions = TableActions;
Table.TitleActions = TableTitleActions;
Table.Container = TableContainer;
Table.HeaderCell = TableHeaderCell;
Table.SettingsMenu = TableSettingsMenu;
Table.Title = TableTitle;
Table.Row = TableRow;
Table.HeaderRow = TableHeaderRow;
Table.Content = TableContent;
Table.Footer = TableFooter;
export {
Table,
TableActions,
TableTitleActions,
TableHeaderCell,
TableSettingsMenu,
TableTitle,
TableContainer,
TableHeaderRow,
TableRow,
};

View File

@ -0,0 +1,15 @@
export interface PaginationTableSettings {
pageSize: number;
}
export interface SortableTableSettings {
sortBy: { id: string; desc: boolean };
}
export interface SettableColumnsTableSettings {
hiddenColumns: string[];
}
export interface RefreshableTableSettings {
autoRefreshRate: number;
}

View File

@ -1,19 +1,61 @@
export interface PaginationTableSettings {
pageSize: number;
setPageSize: (pageSize: number) => void;
}
type Set<T> = (
partial: T | Partial<T> | ((state: T) => T | Partial<T>),
replace?: boolean | undefined
) => void;
export function paginationSettings(
set: Set<PaginationTableSettings>
): PaginationTableSettings {
return {
pageSize: 10,
setPageSize: (pageSize: number) => set({ pageSize }),
};
}
export interface SortableTableSettings {
sortBy: { id: string; desc: boolean };
setSortBy: (id: string, desc: boolean) => void;
}
export function sortableSettings(
set: Set<SortableTableSettings>,
initialSortBy = 'name'
): SortableTableSettings {
return {
sortBy: { id: initialSortBy, desc: false },
setSortBy: (id: string, desc: boolean) => set({ sortBy: { id, desc } }),
};
}
export interface SettableColumnsTableSettings {
hiddenColumns: string[];
setHiddenColumns: (hiddenColumns: string[]) => void;
}
export interface SettableQuickActionsTableSettings<TAction> {
hiddenQuickActions: TAction[];
export function hiddenColumnsSettings(
set: Set<SettableColumnsTableSettings>
): SettableColumnsTableSettings {
return {
hiddenColumns: [],
setHiddenColumns: (hiddenColumns: string[]) => set({ hiddenColumns }),
};
}
export interface RefreshableTableSettings {
autoRefreshRate: number;
setAutoRefreshRate: (autoRefreshRate: number) => void;
}
export function refreshableSettings(
set: Set<RefreshableTableSettings>
): RefreshableTableSettings {
return {
autoRefreshRate: 0,
setAutoRefreshRate: (autoRefreshRate: number) => set({ autoRefreshRate }),
};
}

View File

@ -10,7 +10,7 @@ import {
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
export interface TableSettingsContextInterface<T> {
interface TableSettingsContextInterface<T> {
settings: T;
setTableSettings(partialSettings: Partial<T>): void;
setTableSettings(mutation: (settings: T) => T): void;

View File

@ -0,0 +1,48 @@
import { Context, createContext, ReactNode, useContext, useMemo } from 'react';
interface TableSettingsContextInterface<T> {
settings: T;
}
const TableSettingsContext = createContext<TableSettingsContextInterface<
Record<string, unknown>
> | null>(null);
export function useTableSettings<T>() {
const Context = getContextType<T>();
const context = useContext(Context);
if (context === null) {
throw new Error('must be nested under TableSettingsProvider');
}
return context;
}
interface ProviderProps<T> {
children: ReactNode;
settings: T;
}
export function TableSettingsProvider<T>({
children,
settings,
}: ProviderProps<T>) {
const Context = getContextType<T>();
const contextValue = useMemo(
() => ({
settings,
}),
[settings]
);
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
}
function getContextType<T>() {
return TableSettingsContext as unknown as Context<
TableSettingsContextInterface<T>
>;
}

View File

@ -1,256 +1,105 @@
import { useEffect } from 'react';
import {
useTable,
useSortBy,
useFilters,
useGlobalFilter,
usePagination,
Row,
} from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import _ from 'lodash';
import { useDebounce } from '@/portainer/hooks/useDebounce';
import type {
ContainersTableSettings,
DockerContainer,
} from '@/react/docker/containers/types';
import { useEnvironment } from '@/portainer/environments/useEnvironment';
import { Environment } from '@/portainer/environments/types';
import type { DockerContainer } from '@/react/docker/containers/types';
import { PaginationControls } from '@@/PaginationControls';
import { TableSettingsMenu, Datatable } from '@@/datatables';
import {
QuickActionsSettings,
buildAction,
QuickActionsSettings,
} from '@@/datatables/QuickActionsSettings';
import {
Table,
TableActions,
TableContainer,
TableHeaderRow,
TableRow,
TableSettingsMenu,
TableTitle,
TableTitleActions,
} from '@@/datatables';
import { multiple } from '@@/datatables/filter-types';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
import { useRepeater } from '@@/datatables/useRepeater';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { useRowSelect } from '@@/datatables/useRowSelect';
import { Checkbox } from '@@/form-components/Checkbox';
import { TableFooter } from '@@/datatables/TableFooter';
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
import { ContainersDatatableActions } from './ContainersDatatableActions';
import { useContainers } from '../../queries/containers';
import { createStore } from './datatable-store';
import { ContainersDatatableSettings } from './ContainersDatatableSettings';
import { useColumns } from './columns';
import { ContainersDatatableActions } from './ContainersDatatableActions';
import { RowProvider } from './RowContext';
export interface ContainerTableProps {
isAddActionVisible: boolean;
dataset: DockerContainer[];
onRefresh?(): Promise<void>;
const storageKey = 'containers';
const useStore = createStore(storageKey);
const actions = [
buildAction('logs', 'Logs'),
buildAction('inspect', 'Inspect'),
buildAction('stats', 'Stats'),
buildAction('exec', 'Console'),
buildAction('attach', 'Attach'),
];
export interface Props {
isHostColumnVisible: boolean;
tableKey?: string;
environment: Environment;
}
export function ContainersDatatable({
isAddActionVisible,
dataset,
onRefresh,
isHostColumnVisible,
}: ContainerTableProps) {
const { settings, setTableSettings } =
useTableSettings<ContainersTableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarState('containers');
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<DockerContainer>(
{
defaultCanFilter: false,
columns,
data: dataset,
filterTypes: { multiple },
initialState: {
pageSize: settings.pageSize || 10,
hiddenColumns: settings.hiddenColumns,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
isRowSelectable(row: Row<DockerContainer>) {
return !row.original.IsPortainer;
},
autoResetSelectedRows: false,
getRowId(originalRow: DockerContainer) {
return originalRow.Id;
},
selectCheckboxComponent: Checkbox,
},
useFilters,
useGlobalFilter,
useSortBy,
usePagination,
useRowSelect,
useRowSelectColumn
environment,
}: Props) {
const settings = useStore();
const columns = useColumns(isHostColumnVisible);
const hidableColumns = _.compact(
columns.filter((col) => col.canHide).map((col) => col.id)
);
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();
const containersQuery = useContainers(
environment.Id,
true,
undefined,
settings.autoRefreshRate * 1000
);
return (
<TableContainer>
<TableTitle icon="box" featherIcon label="Containers">
<SearchBar
value={searchBarValue}
onChange={handleSearchBarChange}
placeholder="Search for a container..."
/>
<TableActions>
<RowProvider context={{ environment }}>
<Datatable
titleOptions={{
icon: 'fa-cubes',
title: 'Containers',
}}
settingsStore={settings}
columns={columns}
renderTableActions={(selectedRows) => (
<ContainersDatatableActions
selectedItems={selectedFlatRows.map((row) => row.original)}
isAddActionVisible={isAddActionVisible}
endpointId={endpoint.Id}
/>
</TableActions>
<TableTitleActions>
<ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide}
onChange={handleChangeColumnsVisibility}
value={settings.hiddenColumns}
selectedItems={selectedRows}
isAddActionVisible
endpointId={environment.Id}
/>
)}
isLoading={containersQuery.isLoading}
isRowSelectable={(row) => !row.original.IsPortainer}
initialTableState={{ hiddenColumns: settings.hiddenColumns }}
renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter((colInstance) =>
hidableColumns?.includes(colInstance.id)
);
<TableSettingsMenu
quickActions={<QuickActionsSettings actions={actions} />}
>
<ContainersDatatableSettings isRefreshVisible={!!onRefresh} />
</TableSettingsMenu>
</TableTitleActions>
</TableTitle>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<DockerContainer>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
return (
<>
<ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide}
onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns);
}}
value={settings.hiddenColumns}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{page.length > 0 ? (
page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<TableRow<DockerContainer>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
<TableSettingsMenu
quickActions={<QuickActionsSettings actions={actions} />}
>
<ContainersDatatableSettings
isRefreshVisible
settings={settings}
/>
);
})
) : (
<tr>
<td colSpan={columns.length} className="text-center text-muted">
No container available.
</td>
</tr>
)}
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={dataset.length}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
</TableSettingsMenu>
</>
);
}}
storageKey={storageKey}
dataset={containersQuery.data || []}
emptyContentLabel="No containers found"
/>
</RowProvider>
);
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 },
}));
}
}

View File

@ -4,8 +4,9 @@ import * as notifications from '@/portainer/services/notifications';
import { useAuthorizations, Authorized } from '@/portainer/hooks/useUser';
import { confirmContainerDeletion } from '@/portainer/services/modal.service/prompt';
import { setPortainerAgentTargetHeader } from '@/portainer/services/http-request.helper';
import type {
import {
ContainerId,
ContainerStatus,
DockerContainer,
} from '@/react/docker/containers/types';
import {
@ -40,13 +41,22 @@ export function ContainersDatatableActions({
}: Props) {
const selectedItemCount = selectedItems.length;
const hasPausedItemsSelected = selectedItems.some(
(item) => item.Status === 'paused'
(item) => item.State === ContainerStatus.Paused
);
const hasStoppedItemsSelected = selectedItems.some((item) =>
['stopped', 'created'].includes(item.Status)
[
ContainerStatus.Stopped,
ContainerStatus.Created,
ContainerStatus.Exited,
].includes(item.Status)
);
const hasRunningItemsSelected = selectedItems.some((item) =>
['running', 'healthy', 'unhealthy', 'starting'].includes(item.Status)
[
ContainerStatus.Running,
ContainerStatus.Healthy,
ContainerStatus.Unhealthy,
ContainerStatus.Starting,
].includes(item.Status)
);
const isAuthorized = useAuthorizations([
@ -95,7 +105,7 @@ export function ContainersDatatableActions({
<Button
color="light"
onClick={() => onKillClick(selectedItems)}
disabled={selectedItemCount === 0}
disabled={selectedItemCount === 0 || hasStoppedItemsSelected}
>
<i className="fa fa-bomb space-right" aria-hidden="true" />
Kill
@ -228,7 +238,7 @@ export function ContainersDatatableActions({
function onRemoveClick(selectedItems: DockerContainer[]) {
const isOneContainerRunning = selectedItems.some(
(container) => container.Status === 'running'
(container) => container.State === 'running'
);
const runningTitle = isOneContainerRunning ? 'running' : '';

View File

@ -1,37 +0,0 @@
import { EnvironmentProvider } from '@/portainer/environments/useEnvironment';
import type { Environment } from '@/portainer/environments/types';
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
import {
ContainersDatatable,
ContainerTableProps,
} from './ContainersDatatable';
interface Props extends ContainerTableProps {
endpoint: Environment;
}
export function ContainersDatatableContainer({
endpoint,
tableKey = 'containers',
...props
}: Props) {
const defaultSettings = {
autoRefreshRate: 0,
truncateContainerName: 32,
hiddenQuickActions: [],
hiddenColumns: [],
pageSize: 10,
sortBy: { id: 'state', desc: false },
};
return (
<EnvironmentProvider environment={endpoint}>
<TableSettingsProvider defaults={defaultSettings} storageKey={tableKey}>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<ContainersDatatable {...props} />
</TableSettingsProvider>
</EnvironmentProvider>
);
}

View File

@ -1,17 +1,18 @@
import type { ContainersTableSettings } from '@/react/docker/containers/types';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { Checkbox } from '@@/form-components/Checkbox';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { TableSettings } from './types';
import { TRUNCATE_LENGTH } from './datatable-store';
interface Props {
isRefreshVisible: boolean;
isRefreshVisible?: boolean;
settings: TableSettings;
}
export function ContainersDatatableSettings({ isRefreshVisible }: Props) {
const { settings, setTableSettings } =
useTableSettings<ContainersTableSettings>();
export function ContainersDatatableSettings({
isRefreshVisible,
settings,
}: Props) {
return (
<>
<Checkbox
@ -19,23 +20,18 @@ export function ContainersDatatableSettings({ isRefreshVisible }: Props) {
label="Truncate container name"
checked={settings.truncateContainerName > 0}
onChange={() =>
setTableSettings((settings) => ({
...settings,
truncateContainerName: settings.truncateContainerName > 0 ? 0 : 32,
}))
settings.setTruncateContainerName(
settings.truncateContainerName > 0 ? 0 : TRUNCATE_LENGTH
)
}
/>
{isRefreshVisible && (
<TableSettingsMenuAutoRefresh
value={settings.autoRefreshRate}
onChange={handleRefreshRateChange}
onChange={(value) => settings.setAutoRefreshRate(value)}
/>
)}
</>
);
function handleRefreshRateChange(autoRefreshRate: number) {
setTableSettings({ autoRefreshRate });
}
}

View File

@ -0,0 +1,11 @@
import { Environment } from '@/portainer/environments/types';
import { createRowContext } from '@@/datatables/RowContext';
interface RowContextState {
environment: Environment;
}
const { RowProvider, useRowContext } = createRowContext<RowContextState>();
export { RowProvider, useRowContext };

View File

@ -1,12 +1,28 @@
import { Column } from 'react-table';
import { CellProps, Column } from 'react-table';
import type { DockerContainer } from '@/react/docker/containers/types';
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
import { useContainerGpus } from '@/react/docker/containers/queries/gpus';
export const gpus: Column<DockerContainer> = {
Header: 'GPUs',
accessor: 'Gpus',
id: 'gpus',
disableFilters: true,
canHide: true,
Filter: () => null,
Cell: GpusCell,
};
function GpusCell({
row: { original: container },
}: CellProps<DockerContainer>) {
const containerId = container.Id;
const environmentId = useEnvironmentId();
const gpusQuery = useContainerGpus(environmentId, containerId);
if (!gpusQuery.data) {
return null;
}
return <>{gpusQuery.data}</>;
}

View File

@ -1,9 +1,9 @@
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 '@/react/docker/containers/types';
import { isOfflineEndpoint } from '@/portainer/helpers/endpointHelper';
import { useCurrentEnvironment } from '@/portainer/hooks/useCurrentEnvironment';
export const image: Column<DockerContainer> = {
Header: 'Image',
@ -21,14 +21,15 @@ interface Props {
}
function ImageCell({ value: imageName }: Props) {
const endpoint = useEnvironment();
const offlineMode = endpoint.Status !== EnvironmentStatus.Up;
const linkProps = useSref('docker.images.image', { id: imageName });
const shortImageName = trimSHASum(imageName);
const linkProps = useSref('docker.images.image', { id: imageName });
if (offlineMode) {
return shortImageName;
const environmentQuery = useCurrentEnvironment();
const environment = environmentQuery.data;
if (!environment || isOfflineEndpoint(environment)) {
return <span>{shortImageName}</span>;
}
return (

View File

@ -1,3 +1,4 @@
import _ from 'lodash';
import { useMemo } from 'react';
import { created } from './created';
@ -12,21 +13,22 @@ import { stack } from './stack';
import { state } from './state';
import { gpus } from './gpus';
export function useColumns() {
export function useColumns(isHostColumnVisible: boolean) {
return useMemo(
() => [
name,
state,
quickActions,
stack,
image,
created,
ip,
host,
gpus,
ports,
ownership,
],
[]
() =>
_.compact([
name,
state,
quickActions,
stack,
image,
created,
ip,
isHostColumnVisible && host,
gpus,
ports,
ownership,
]),
[isHostColumnVisible]
);
}

View File

@ -1,14 +1,14 @@
import { CellProps, Column, TableInstance } from 'react-table';
import { CellProps, Column } from 'react-table';
import _ from 'lodash';
import { useSref } from '@uirouter/react';
import { useEnvironment } from '@/portainer/environments/useEnvironment';
import type {
ContainersTableSettings,
DockerContainer,
} from '@/react/docker/containers/types';
import type { DockerContainer } from '@/react/docker/containers/types';
import { isOfflineEndpoint } from '@/portainer/helpers/endpointHelper';
import { useCurrentEnvironment } from '@/portainer/hooks/useCurrentEnvironment';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { useTableSettings } from '@@/datatables/useZustandTableSettings';
import { TableSettings } from '../types';
export const name: Column<DockerContainer> = {
Header: 'Name',
@ -27,23 +27,24 @@ export const name: Column<DockerContainer> = {
export function NameCell({
value: name,
row: { original: container },
}: CellProps<TableInstance>) {
const { settings } = useTableSettings<ContainersTableSettings>();
const truncate = settings.truncateContainerName;
const endpoint = useEnvironment();
const offlineMode = endpoint.Status !== 1;
const linkProps = useSref('docker.containers.container', {
}: CellProps<DockerContainer>) {
const linkProps = useSref('.container', {
id: container.Id,
nodeName: container.NodeName,
});
const { settings } = useTableSettings<TableSettings>();
const truncate = settings.truncateContainerName;
const environmentQuery = useCurrentEnvironment();
const environment = environmentQuery.data;
let shortName = name;
if (truncate > 0) {
shortName = _.truncate(name, { length: truncate });
}
if (offlineMode) {
if (!environment || isOfflineEndpoint(environment)) {
return <span>{shortName}</span>;
}

View File

@ -1,8 +1,8 @@
import { Column } from 'react-table';
import _ from 'lodash';
import { useEnvironment } from '@/portainer/environments/useEnvironment';
import type { DockerContainer, Port } from '@/react/docker/containers/types';
import { useCurrentEnvironment } from '@/portainer/hooks/useCurrentEnvironment';
export const ports: Column<DockerContainer> = {
Header: 'Published Ports',
@ -20,12 +20,15 @@ interface Props {
}
function PortsCell({ value: ports }: Props) {
const { PublicURL: publicUrl } = useEnvironment();
const environmentQuery = useCurrentEnvironment();
if (ports.length === 0) {
const environment = environmentQuery.data;
if (!environment || ports.length === 0) {
return '-';
}
const { PublicURL: publicUrl } = environment;
return _.uniqBy(ports, 'public').map((port) => (
<a
key={`${port.host}:${port.public}`}

View File

@ -1,15 +1,14 @@
import { CellProps, Column } from 'react-table';
import { useEnvironment } from '@/portainer/environments/useEnvironment';
import { useAuthorizations } from '@/portainer/hooks/useUser';
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions/ContainerQuickActions';
import type {
ContainersTableSettings,
DockerContainer,
} from '@/react/docker/containers/types';
import { EnvironmentStatus } from '@/portainer/environments/types';
import { isOfflineEndpoint } from '@/portainer/helpers/endpointHelper';
import { useCurrentEnvironment } from '@/portainer/hooks/useCurrentEnvironment';
import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions';
import { DockerContainer } from '@/react/docker/containers/types';
import { useTableSettings } from '@@/datatables/useTableSettings';
import { useTableSettings } from '@@/datatables/useZustandTableSettings';
import { TableSettings } from '../types';
export const quickActions: Column<DockerContainer> = {
Header: 'Quick Actions',
@ -25,10 +24,12 @@ export const quickActions: Column<DockerContainer> = {
function QuickActionsCell({
row: { original: container },
}: CellProps<DockerContainer>) {
const endpoint = useEnvironment();
const offlineMode = endpoint.Status !== EnvironmentStatus.Up;
const environmentQuery = useCurrentEnvironment();
const { settings } = useTableSettings<ContainersTableSettings>();
const environment = environmentQuery.data;
const offlineMode = !environment || isOfflineEndpoint(environment);
const { settings } = useTableSettings<TableSettings>();
const { hiddenQuickActions = [] } = settings;

View File

@ -1,10 +1,9 @@
import { Column } from 'react-table';
import { CellProps, Column } from 'react-table';
import clsx from 'clsx';
import _ from 'lodash';
import type {
DockerContainer,
DockerContainerStatus,
import {
type DockerContainer,
ContainerStatus,
} from '@/react/docker/containers/types';
import { DefaultFilter } from '@@/datatables/Filter';
@ -20,11 +19,14 @@ export const state: Column<DockerContainer> = {
canHide: true,
};
function StatusCell({ value: status }: { value: DockerContainerStatus }) {
const statusNormalized = _.toLower(status);
const hasHealthCheck = ['starting', 'healthy', 'unhealthy'].includes(
statusNormalized
);
function StatusCell({
value: status,
}: CellProps<DockerContainer, ContainerStatus>) {
const hasHealthCheck = [
ContainerStatus.Starting,
ContainerStatus.Healthy,
ContainerStatus.Unhealthy,
].includes(status);
const statusClassName = getClassName();
@ -40,22 +42,21 @@ function StatusCell({ value: status }: { value: DockerContainerStatus }) {
);
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));
switch (status) {
case ContainerStatus.Paused:
case ContainerStatus.Starting:
case ContainerStatus.Unhealthy:
return 'warning';
case ContainerStatus.Created:
return 'info';
case ContainerStatus.Stopped:
case ContainerStatus.Dead:
case ContainerStatus.Exited:
return 'danger';
case ContainerStatus.Healthy:
case ContainerStatus.Running:
default:
return 'success';
}
}
}

View File

@ -0,0 +1,40 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
refreshableSettings,
hiddenColumnsSettings,
} from '@/react/components/datatables/types';
import { QuickAction, TableSettings } from './types';
export const TRUNCATE_LENGTH = 32;
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
...hiddenColumnsSettings(set),
...refreshableSettings(set),
truncateContainerName: TRUNCATE_LENGTH,
setTruncateContainerName(truncateContainerName: number) {
set({
truncateContainerName,
});
},
hiddenQuickActions: [] as QuickAction[],
setHiddenQuickActions: (hiddenQuickActions: QuickAction[]) =>
set({ hiddenQuickActions }),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View File

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

View File

@ -0,0 +1,23 @@
import {
PaginationTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
SortableTableSettings,
} from '@/react/components/datatables/types';
export type QuickAction = 'attach' | 'exec' | 'inspect' | 'logs' | 'stats';
export interface SettableQuickActionsTableSettings<TAction> {
hiddenQuickActions: TAction[];
setHiddenQuickActions: (hiddenQuickActions: TAction[]) => void;
}
export interface TableSettings
extends SortableTableSettings,
PaginationTableSettings,
SettableColumnsTableSettings,
SettableQuickActionsTableSettings<QuickAction>,
RefreshableTableSettings {
truncateContainerName: number;
setTruncateContainerName: (value: number) => void;
}

View File

@ -0,0 +1,38 @@
import { useInfo } from '@/docker/services/system.service';
import { Environment } from '@/portainer/environments/types';
import { isAgentEnvironment } from '@/portainer/environments/utils';
import { PageHeader } from '@@/PageHeader';
import { ContainersDatatable } from './ContainersDatatable';
interface Props {
endpoint: Environment;
}
export function ListView({ endpoint: environment }: Props) {
const isAgent = isAgentEnvironment(environment.Type);
const envInfoQuery = useInfo(environment.Id, (info) => !!info.Swarm?.NodeID);
const isSwarmManager = !!envInfoQuery.data;
const isHostColumnVisible = isAgent && isSwarmManager;
return (
<>
<PageHeader
title="Container list"
breadcrumbs={[{ label: 'Containers' }]}
reload
/>
<div className="row">
<div className="col-sm-12">
<ContainersDatatable
isHostColumnVisible={isHostColumnVisible}
environment={environment}
/>
</div>
</div>
</>
);
}

View File

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

View File

@ -1,10 +1,9 @@
import clsx from 'clsx';
import { DockerContainerStatus } from '@/react/docker/containers/types';
import { ContainerStatus } from '@/react/docker/containers/types';
import { Authorized } from '@/portainer/hooks/useUser';
import { Icon } from '@/react/components/Icon';
import { react2angular } from '@/react-tools/react2angular';
import { Icon } from '@@/Icon';
import { Link } from '@@/Link';
import styles from './ContainerQuickActions.module.css';
@ -22,7 +21,7 @@ interface Props {
containerId?: string;
nodeName: string;
state: QuickActionsState;
status: DockerContainerStatus;
status: ContainerStatus;
}
export function ContainerQuickActions({
@ -36,9 +35,12 @@ export function ContainerQuickActions({
return <TaskQuickActions taskId={taskId} state={state} />;
}
const isActive = ['starting', 'running', 'healthy', 'unhealthy'].includes(
status
);
const isActive = [
ContainerStatus.Starting,
ContainerStatus.Running,
ContainerStatus.Healthy,
ContainerStatus.Unhealthy,
].includes(status);
return (
<div className={clsx('space-x-1', styles.root)}>
@ -135,8 +137,3 @@ function TaskQuickActions({ taskId, state }: TaskProps) {
</div>
);
}
export const ContainerQuickActionsAngular = react2angular(
ContainerQuickActions,
['taskId', 'containerId', 'nodeName', 'state', 'status']
);

View File

@ -1,4 +1 @@
export {
ContainerQuickActions,
ContainerQuickActionsAngular,
} from './ContainerQuickActions';
export { ContainerQuickActions } from './ContainerQuickActions';

View File

@ -3,15 +3,8 @@ import PortainerError from '@/portainer/error';
import axios from '@/portainer/services/axios';
import { genericHandler } from '@/docker/rest/response/handlers';
import { NetworkId } from '../networks/types';
import { ContainerId, DockerContainer } from './types';
export interface Filters {
label?: string[];
network?: NetworkId[];
}
export async function startContainer(
endpointId: EnvironmentId,
id: ContainerId
@ -92,25 +85,7 @@ export async function removeContainer(
}
}
export async function getContainers(
environmentId: EnvironmentId,
filters?: Filters
) {
try {
const { data } = await axios.get<DockerContainer[]>(
urlBuilder(environmentId, '', 'json'),
{
params: { all: 0, filters },
}
);
return data;
} catch (e) {
throw new PortainerError('Unable to retrieve containers', e as Error);
}
}
function urlBuilder(
export function urlBuilder(
endpointId: EnvironmentId,
id?: ContainerId,
action?: string

View File

@ -1,18 +0,0 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import { getContainers, Filters } from './containers.service';
export function useContainers(environmentId: EnvironmentId, filters?: Filters) {
return useQuery(
['environments', environmentId, 'docker', 'containers', { filters }],
() => getContainers(environmentId, filters),
{
meta: {
title: 'Failure',
message: 'Unable to get containers in network',
},
}
);
}

View File

@ -0,0 +1,50 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { urlBuilder } from '../containers.service';
import { DockerContainerResponse } from '../types/response';
import { parseViewModel } from '../utils';
import { Filters } from './types';
import { queryKeys } from './query-keys';
export function useContainers(
environmentId: EnvironmentId,
all = true,
filters?: Filters,
autoRefreshRate?: number
) {
return useQuery(
queryKeys.filters(environmentId, all, filters),
() => getContainers(environmentId, all, filters),
{
meta: {
title: 'Failure',
message: 'Unable to retrieve containers',
},
refetchInterval() {
return autoRefreshRate ?? false;
},
}
);
}
async function getContainers(
environmentId: EnvironmentId,
all = true,
filters?: Filters
) {
try {
const { data } = await axios.get<DockerContainerResponse[]>(
urlBuilder(environmentId, undefined, 'json'),
{
params: { all, filters: filters && JSON.stringify(filters) },
}
);
return data.map((c) => parseViewModel(c));
} catch (error) {
throw parseAxiosError(error as Error, 'Unable to retrieve containers');
}
}

View File

@ -0,0 +1,29 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { queryKeys } from './query-keys';
async function getContainerGpus(
environmentId: EnvironmentId,
containerId: string
) {
try {
const { data } = await axios.get<{ gpus: string }>(
`/docker/${environmentId}/containers/${containerId}/gpus`
);
return data.gpus;
} catch (err) {
throw parseAxiosError(err as Error);
}
}
export function useContainerGpus(
environmentId: EnvironmentId,
containerId: string
) {
return useQuery(queryKeys.gpus(environmentId, containerId), () =>
getContainerGpus(environmentId, containerId)
);
}

View File

@ -0,0 +1,19 @@
import { EnvironmentId } from '@/portainer/environments/types';
import { queryKeys as dockerQueryKeys } from '../../queries/utils';
import { Filters } from './types';
export const queryKeys = {
list: (environmentId: EnvironmentId) =>
[dockerQueryKeys.root(environmentId), 'containers'] as const,
filters: (environmentId: EnvironmentId, all?: boolean, filters?: Filters) =>
[...queryKeys.list(environmentId), { all, filters }] as const,
container: (environmentId: EnvironmentId, id: string) =>
[...queryKeys.list(environmentId), id] as const,
gpus: (environmentId: EnvironmentId, id: string) =>
[...queryKeys.container(environmentId, id), 'gpus'] as const,
};

View File

@ -0,0 +1,6 @@
import { NetworkId } from '../../networks/types';
export interface Filters {
label?: string[];
network?: NetworkId[];
}

View File

@ -1,54 +1,40 @@
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import {
PaginationTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
SettableQuickActionsTableSettings,
SortableTableSettings,
} from '@@/datatables/types';
import { DockerContainerResponse } from './types/response';
export type DockerContainerStatus =
| 'paused'
| 'stopped'
| 'created'
| 'healthy'
| 'unhealthy'
| 'starting'
| 'running'
| 'dead'
| 'exited';
export enum ContainerStatus {
Paused = 'paused',
Stopped = 'stopped',
Created = 'created',
Healthy = 'healthy',
Unhealthy = 'unhealthy',
Starting = 'starting',
Running = 'running',
Dead = 'dead',
Exited = 'exited',
}
export type QuickAction = 'attach' | 'exec' | 'inspect' | 'logs' | 'stats';
export interface ContainersTableSettings
extends SortableTableSettings,
PaginationTableSettings,
SettableColumnsTableSettings,
SettableQuickActionsTableSettings<QuickAction>,
RefreshableTableSettings {
truncateContainerName: number;
}
export interface Port {
host: string;
public: string;
private: string;
host?: string;
public: number;
private: number;
}
export type ContainerId = string;
export type DockerContainer = {
IsPortainer: boolean;
Status: DockerContainerStatus;
type DecoratedDockerContainer = {
NodeName: string;
Id: ContainerId;
ResourceControl?: ResourceControlViewModel;
IP: string;
Names: string[];
Created: string;
ResourceControl: ResourceControlViewModel;
Ports: Port[];
StackName?: string;
Status: ContainerStatus;
Ports: Port[];
StatusText: string;
Image: string;
Gpus: string;
};
export type DockerContainer = DecoratedDockerContainer &
Omit<DockerContainerResponse, keyof DecoratedDockerContainer>;

View File

@ -0,0 +1,94 @@
import { PortainerMetadata } from '@/react/docker/types';
interface EndpointIPAMConfig {
IPv4Address?: string;
IPv6Address?: string;
LinkLocalIPs?: string[];
}
interface EndpointSettings {
IPAMConfig?: EndpointIPAMConfig;
Links: string[];
Aliases: string[];
NetworkID: string;
EndpointID: string;
Gateway: string;
IPAddress: string;
IPPrefixLen: number;
IPv6Gateway: string;
GlobalIPv6Address: string;
GlobalIPv6PrefixLen: number;
MacAddress: string;
DriverOpts: { [key: string]: string };
}
export interface SummaryNetworkSettings {
Networks: { [key: string]: EndpointSettings | undefined };
}
interface PortResponse {
IP?: string;
PrivatePort: number;
PublicPort?: number;
Type: string;
}
enum MountPropagation {
// PropagationRPrivate RPRIVATE
RPrivate = 'rprivate',
// PropagationPrivate PRIVATE
Private = 'private',
// PropagationRShared RSHARED
RShared = 'rshared',
// PropagationShared SHARED
Shared = 'shared',
// PropagationRSlave RSLAVE
RSlave = 'rslave',
// PropagationSlave SLAVE
Slave = 'slave',
}
enum MountType {
// TypeBind is the type for mounting host dir
Bind = 'bind',
// TypeVolume is the type for remote storage volumes
Volume = 'volume',
// TypeTmpfs is the type for mounting tmpfs
Tmpfs = 'tmpfs',
// TypeNamedPipe is the type for mounting Windows named pipes
NamedPipe = 'npipe',
}
interface MountPoint {
Type?: MountType;
Name?: string;
Source: string;
Destination: string;
Driver?: string;
Mode: string;
RW: boolean;
Propagation: MountPropagation;
}
export interface DockerContainerResponse {
Id: string;
Names: string[];
Image: string;
ImageID: string;
Command: string;
Created: number;
Ports: PortResponse[];
SizeRw?: number;
SizeRootFs?: number;
Labels: { [key: string]: string };
State: string;
Status: string;
HostConfig: {
NetworkMode?: string;
};
NetworkSettings?: SummaryNetworkSettings;
Mounts: MountPoint[];
Portainer: PortainerMetadata;
IsPortainer: boolean;
}

View File

@ -0,0 +1,87 @@
import _ from 'lodash';
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import { DockerContainer, ContainerStatus } from './types';
import { DockerContainerResponse } from './types/response';
export function parseViewModel(
response: DockerContainerResponse
): DockerContainer {
const resourceControl =
response.Portainer?.ResourceControl &&
new ResourceControlViewModel(response?.Portainer?.ResourceControl);
const nodeName = response.Portainer?.Agent?.NodeName || '';
const ip =
Object.values(response?.NetworkSettings?.Networks || {})[0]?.IPAddress ||
'';
const labels = response.Labels || {};
const stackName =
labels['com.docker.compose.project'] ||
labels['com.docker.stack.namespace'];
const status = createStatus(response.Status);
const ports = _.compact(
response.Ports?.map(
(p) =>
p.PublicPort && {
host: p.IP,
private: p.PrivatePort,
public: p.PublicPort,
}
)
);
return {
...response,
ResourceControl: resourceControl,
NodeName: nodeName,
IP: ip,
StackName: stackName,
Status: status,
Ports: ports,
StatusText: response.Status,
Gpus: '',
};
}
function createStatus(statusText = ''): ContainerStatus {
const status = statusText.toLowerCase();
if (status.includes(ContainerStatus.Paused)) {
return ContainerStatus.Paused;
}
if (status.includes(ContainerStatus.Dead)) {
return ContainerStatus.Dead;
}
if (status.includes(ContainerStatus.Created)) {
return ContainerStatus.Created;
}
if (status.includes(ContainerStatus.Stopped)) {
return ContainerStatus.Stopped;
}
if (status.includes(ContainerStatus.Exited)) {
return ContainerStatus.Exited;
}
if (status.includes('(healthy)')) {
return ContainerStatus.Healthy;
}
if (status.includes('(unhealthy)')) {
return ContainerStatus.Unhealthy;
}
if (status.includes('(health: starting)')) {
return ContainerStatus.Starting;
}
return ContainerStatus.Running;
}

View File

@ -9,13 +9,13 @@ import { AccessControlPanel } from '@/portainer/access-control/AccessControlPane
import { ResourceControlType } from '@/portainer/access-control/types';
import { DockerContainer } from '@/react/docker/containers/types';
import { ResourceControlViewModel } from '@/portainer/access-control/models/ResourceControlViewModel';
import { useContainers } from '@/react/docker/containers/queries/containers';
import { PageHeader } from '@@/PageHeader';
import { useNetwork, useDeleteNetwork } from '../queries';
import { isSystemNetwork } from '../network.helper';
import { DockerNetwork, NetworkContainer } from '../types';
import { useContainers } from '../../containers/queries';
import { NetworkDetailsTable } from './NetworkDetailsTable';
import { NetworkOptionsTable } from './NetworkOptionsTable';
@ -38,7 +38,7 @@ export function ItemView() {
const filters = {
network: [networkId],
};
const containersQuery = useContainers(environmentId, filters);
const containersQuery = useContainers(environmentId, true, filters);
useEffect(() => {
if (networkQuery.data && containersQuery.data) {

View File

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

View File

@ -0,0 +1,99 @@
import _ from 'lodash';
import { DockerContainer } from '@/react/docker/containers/types';
import { Environment } from '@/portainer/environments/types';
import { createStore } from '@/react/docker/containers/ListView/ContainersDatatable/datatable-store';
import { useColumns } from '@/react/docker/containers/ListView/ContainersDatatable/columns';
import { ContainersDatatableActions } from '@/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions';
import { ContainersDatatableSettings } from '@/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableSettings';
import { Datatable, TableSettingsMenu } from '@@/datatables';
import {
buildAction,
QuickActionsSettings,
} from '@@/datatables/QuickActionsSettings';
import { ColumnVisibilityMenu } from '@@/datatables/ColumnVisibilityMenu';
import { useContainers } from '../../containers/queries/containers';
import { RowProvider } from '../../containers/ListView/ContainersDatatable/RowContext';
const storageKey = 'stack-containers';
const useStore = createStore(storageKey);
const actions = [
buildAction('logs', 'Logs'),
buildAction('inspect', 'Inspect'),
buildAction('stats', 'Stats'),
buildAction('exec', 'Console'),
buildAction('attach', 'Attach'),
];
export interface Props {
environment: Environment;
stackName: string;
}
export function StackContainersDatatable({ environment, stackName }: Props) {
const settings = useStore();
const columns = useColumns(false);
const hidableColumns = _.compact(
columns.filter((col) => col.canHide).map((col) => col.id)
);
const containersQuery = useContainers(
environment.Id,
true,
{
label: [`com.docker.compose.project=${stackName}`],
},
settings.autoRefreshRate * 1000
);
return (
<RowProvider context={{ environment }}>
<Datatable
titleOptions={{
icon: 'fa-cubes',
title: 'Containers',
}}
settingsStore={settings}
columns={columns}
renderTableActions={(selectedRows) => (
<ContainersDatatableActions
selectedItems={selectedRows}
isAddActionVisible={false}
endpointId={environment.Id}
/>
)}
initialTableState={{ hiddenColumns: settings.hiddenColumns }}
renderTableSettings={(tableInstance) => {
const columnsToHide = tableInstance.allColumns.filter((colInstance) =>
hidableColumns?.includes(colInstance.id)
);
return (
<>
<ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide}
onChange={(hiddenColumns) => {
settings.setHiddenColumns(hiddenColumns);
tableInstance.setHiddenColumns(hiddenColumns);
}}
value={settings.hiddenColumns}
/>
<TableSettingsMenu
quickActions={<QuickActionsSettings actions={actions} />}
>
<ContainersDatatableSettings settings={settings} />
</TableSettingsMenu>
</>
);
}}
storageKey={storageKey}
dataset={containersQuery.data || []}
isLoading={containersQuery.isLoading}
emptyContentLabel="No containers found"
/>
</RowProvider>
);
}

View File

@ -0,0 +1,23 @@
export enum StackType {
/**
* Represents a stack managed via docker stack
*/
DockerSwarm = 1,
/**
* Represents a stack managed via docker-compose
*/
DockerCompose,
/**
* Represents a stack managed via kubectl
*/
Kubernetes,
/**
* Represents a stack managed via Nomad
*/
Nomad,
}
export enum StackStatus {
Active = 1,
Inactive,
}

View File

@ -1,5 +1,10 @@
import { ResourceControlResponse } from '@/portainer/access-control/types';
export interface PortainerMetadata {
ResourceControl: ResourceControlResponse;
interface AgentMetadata {
NodeName: string;
}
export interface PortainerMetadata {
ResourceControl?: ResourceControlResponse;
Agent?: AgentMetadata;
}

View File

@ -143,7 +143,8 @@
"x256": "^0.0.2",
"xterm": "^3.8.0",
"yaml": "^1.10.2",
"yup": "^0.32.11"
"yup": "^0.32.11",
"zustand": "^4.0.0"
},
"devDependencies": {
"@apidevtools/swagger-cli": "^4.0.4",

View File

@ -18179,6 +18179,11 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
use@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
@ -19038,6 +19043,13 @@ z-schema@^5.0.1:
optionalDependencies:
commander "^2.7.1"
zustand@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.0.0.tgz#739cba69209ffe67b31e7d6741c25b51496114a7"
integrity sha512-OrsfQTnRXF1LZ9/vR/IqN9ws5EXUhb149xmPjErZnUrkgxS/gAHGy2dPNIVkVvoxrVe1sIydn4JjF0dYHmGeeQ==
dependencies:
use-sync-external-store "1.2.0"
zwitch@^1.0.0:
version "1.0.5"
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"