Compare commits
17 Commits
2.20.2
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439714f93d | ||
|
|
2745e63527 | ||
|
|
24e0318280 | ||
|
|
9a079a83fa | ||
|
|
1df6087c8e | ||
|
|
ae705bc245 | ||
|
|
d725b5e3b6 | ||
|
|
1b33b1f5dd | ||
|
|
b70f0fe3d2 | ||
|
|
55ef46edb6 | ||
|
|
c2654d55b3 | ||
|
|
7fab352dbf | ||
|
|
0dcb5113f7 | ||
|
|
a1b0634d86 | ||
|
|
da134c3e3f | ||
|
|
5191fc9220 | ||
|
|
af4e362c5c |
@@ -8,7 +8,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
initMSW(
|
||||
{
|
||||
onUnhandledRequest: ({ method, url }) => {
|
||||
console.log(method, url);
|
||||
if (url.startsWith('/api')) {
|
||||
console.error(`Unhandled ${method} request to ${url}.
|
||||
|
||||
|
||||
@@ -462,7 +462,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService(*flags.BaseURL, *flags.AddrHTTPS, sslSettings.CertPath)
|
||||
|
||||
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService)
|
||||
proxyManager := proxy.NewManager(kubernetesClientFactory)
|
||||
|
||||
reverseTunnelService.ProxyManager = proxyManager
|
||||
|
||||
@@ -490,6 +490,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
}
|
||||
snapshotService.Start()
|
||||
|
||||
proxyManager.NewProxyFactory(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
||||
|
||||
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
||||
|
||||
95
api/datastore/pendingactions_test.go
Normal file
95
api/datastore/pendingactions_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
)
|
||||
|
||||
func Test_ConvertCleanNAPWithOverridePoliciesPayload(t *testing.T) {
|
||||
t.Run("test ConvertCleanNAPWithOverridePoliciesPayload", func(t *testing.T) {
|
||||
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
defer store.Close()
|
||||
|
||||
testData := []struct {
|
||||
Name string
|
||||
PendingAction portainer.PendingActions
|
||||
Expected *actions.CleanNAPWithOverridePoliciesPayload
|
||||
Err bool
|
||||
}{
|
||||
{
|
||||
Name: "test actiondata with EndpointGroupID 1",
|
||||
PendingAction: portainer.PendingActions{
|
||||
EndpointID: 1,
|
||||
Action: "CleanNAPWithOverridePolicies",
|
||||
ActionData: &actions.CleanNAPWithOverridePoliciesPayload{
|
||||
EndpointGroupID: 1,
|
||||
},
|
||||
},
|
||||
Expected: &actions.CleanNAPWithOverridePoliciesPayload{
|
||||
EndpointGroupID: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "test actionData nil",
|
||||
PendingAction: portainer.PendingActions{
|
||||
EndpointID: 2,
|
||||
Action: "CleanNAPWithOverridePolicies",
|
||||
ActionData: nil,
|
||||
},
|
||||
Expected: nil,
|
||||
},
|
||||
{
|
||||
Name: "test actionData empty and expected error",
|
||||
PendingAction: portainer.PendingActions{
|
||||
EndpointID: 2,
|
||||
Action: "CleanNAPWithOverridePolicies",
|
||||
ActionData: "",
|
||||
},
|
||||
Expected: nil,
|
||||
Err: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, d := range testData {
|
||||
err := store.PendingActions().Create(&d.PendingAction)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
pendingActions, err := store.PendingActions().ReadAll()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, endpointPendingAction := range pendingActions {
|
||||
t.Run(d.Name, func(t *testing.T) {
|
||||
if endpointPendingAction.Action == "CleanNAPWithOverridePolicies" {
|
||||
actionData, err := actions.ConvertCleanNAPWithOverridePoliciesPayload(endpointPendingAction.ActionData)
|
||||
if d.Err && err == nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if d.Expected == nil && actionData != nil {
|
||||
t.Errorf("expected nil , got %d", actionData)
|
||||
}
|
||||
|
||||
if d.Expected != nil && actionData == nil {
|
||||
t.Errorf("expected not nil , got %d", actionData)
|
||||
}
|
||||
|
||||
if d.Expected != nil && actionData.EndpointGroupID != d.Expected.EndpointGroupID {
|
||||
t.Errorf("expected EndpointGroupID %d , got %d", d.Expected.EndpointGroupID, actionData.EndpointGroupID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
store.PendingActions().Delete(d.PendingAction.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -941,6 +941,6 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.20.2\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.20.3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,9 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
|
||||
|
||||
imageUsageSet := set.Set[string]{}
|
||||
if withUsage {
|
||||
containers, err := cli.ContainerList(r.Context(), container.ListOptions{})
|
||||
containers, err := cli.ContainerList(r.Context(), container.ListOptions{
|
||||
All: true,
|
||||
})
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve Docker containers", err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/tag"
|
||||
pendingActionActions "github.com/portainer/portainer/api/pendingactions/actions"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -159,7 +160,9 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
err := handler.PendingActionsService.Create(portainer.PendingActions{
|
||||
EndpointID: endpointID,
|
||||
Action: "CleanNAPWithOverridePolicies",
|
||||
ActionData: endpointGroupID,
|
||||
ActionData: &pendingActionActions.CleanNAPWithOverridePoliciesPayload{
|
||||
EndpointGroupID: endpointGroupID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID)
|
||||
|
||||
@@ -21,7 +21,8 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), demo.NewService())
|
||||
handler.DataStore = store
|
||||
handler.ProxyManager = proxy.NewManager(nil, nil, nil, nil, nil, nil, nil)
|
||||
handler.ProxyManager = proxy.NewManager(nil)
|
||||
handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
// Create all the environments and add them to the same edge group
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.20.2
|
||||
// @version 2.20.3
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
|
||||
DockerClientFactory: factory.dockerClientFactory,
|
||||
}
|
||||
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, httpTransport, factory.gitService)
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, httpTransport, factory.gitService, factory.snapshotService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ type (
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
dockerClientFactory *dockerclient.ClientFactory
|
||||
gitService portainer.GitService
|
||||
snapshotService portainer.SnapshotService
|
||||
}
|
||||
|
||||
// TransportParameters is used to create a new Transport
|
||||
@@ -63,7 +64,7 @@ type (
|
||||
)
|
||||
|
||||
// NewTransport returns a pointer to a new Transport instance.
|
||||
func NewTransport(parameters *TransportParameters, httpTransport *http.Transport, gitService portainer.GitService) (*Transport, error) {
|
||||
func NewTransport(parameters *TransportParameters, httpTransport *http.Transport, gitService portainer.GitService, snapshotService portainer.SnapshotService) (*Transport, error) {
|
||||
transport := &Transport{
|
||||
endpoint: parameters.Endpoint,
|
||||
dataStore: parameters.DataStore,
|
||||
@@ -72,6 +73,7 @@ func NewTransport(parameters *TransportParameters, httpTransport *http.Transport
|
||||
dockerClientFactory: parameters.DockerClientFactory,
|
||||
HTTPTransport: httpTransport,
|
||||
gitService: gitService,
|
||||
snapshotService: snapshotService,
|
||||
}
|
||||
|
||||
return transport, nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
@@ -48,6 +49,14 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
|
||||
if responseObject["Volumes"] != nil {
|
||||
volumeData := responseObject["Volumes"].([]interface{})
|
||||
|
||||
if transport.snapshotService != nil {
|
||||
// Filling snapshot data can improve the performance of getVolumeResourceID
|
||||
if err = transport.snapshotService.FillSnapshotData(transport.endpoint); err != nil {
|
||||
log.Info().Err(err).
|
||||
Int("endpoint id", int(transport.endpoint.ID)).
|
||||
Msg("snapshot is not filled into the endpoint.")
|
||||
}
|
||||
}
|
||||
for _, volumeObject := range volumeData {
|
||||
volume := volumeObject.(map[string]interface{})
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
|
||||
|
||||
proxy := &dockerLocalProxy{}
|
||||
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path), factory.gitService)
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path), factory.gitService, factory.snapshotService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
|
||||
|
||||
proxy := &dockerLocalProxy{}
|
||||
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService)
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService, factory.snapshotService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -23,11 +23,12 @@ type (
|
||||
kubernetesClientFactory *cli.ClientFactory
|
||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
gitService portainer.GitService
|
||||
snapshotService portainer.SnapshotService
|
||||
}
|
||||
)
|
||||
|
||||
// NewProxyFactory returns a pointer to a new instance of a ProxyFactory
|
||||
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService) *ProxyFactory {
|
||||
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) *ProxyFactory {
|
||||
return &ProxyFactory{
|
||||
dataStore: dataStore,
|
||||
signatureService: signatureService,
|
||||
@@ -36,6 +37,7 @@ func NewProxyFactory(dataStore dataservices.DataStore, signatureService portaine
|
||||
kubernetesClientFactory: kubernetesClientFactory,
|
||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
gitService: gitService,
|
||||
snapshotService: snapshotService,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,17 +25,24 @@ type (
|
||||
)
|
||||
|
||||
// NewManager initializes a new proxy Service
|
||||
func NewManager(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService) *Manager {
|
||||
func NewManager(kubernetesClientFactory *cli.ClientFactory) *Manager {
|
||||
return &Manager{
|
||||
endpointProxies: cmap.New(),
|
||||
k8sClientFactory: kubernetesClientFactory,
|
||||
proxyFactory: factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService),
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) {
|
||||
manager.proxyFactory = factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
||||
}
|
||||
|
||||
// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
|
||||
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
|
||||
func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
if manager.proxyFactory == nil {
|
||||
return nil, fmt.Errorf("proxy factory not init")
|
||||
}
|
||||
|
||||
proxy, err := manager.proxyFactory.NewEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -48,6 +55,9 @@ func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpo
|
||||
// CreateAgentProxyServer creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
|
||||
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
|
||||
func (manager *Manager) CreateAgentProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) {
|
||||
if manager.proxyFactory == nil {
|
||||
return nil, fmt.Errorf("proxy factory not init")
|
||||
}
|
||||
return manager.proxyFactory.NewAgentProxy(endpoint)
|
||||
}
|
||||
|
||||
@@ -74,5 +84,8 @@ func (manager *Manager) DeleteEndpointProxy(endpointID portainer.EndpointID) {
|
||||
|
||||
// CreateGitlabProxy creates a new HTTP reverse proxy that can be used to send requests to the Gitlab API
|
||||
func (manager *Manager) CreateGitlabProxy(url string) (http.Handler, error) {
|
||||
if manager.proxyFactory == nil {
|
||||
return nil, fmt.Errorf("proxy factory not init")
|
||||
}
|
||||
return manager.proxyFactory.NewGitlabProxy(url)
|
||||
}
|
||||
|
||||
@@ -125,12 +125,27 @@ func GetNamespace(manifestYaml []byte) (string, error) {
|
||||
return "", errors.Wrap(err, "failed to unmarshal yaml manifest when obtaining namespace")
|
||||
}
|
||||
|
||||
if _, ok := m["metadata"]; ok {
|
||||
if namespace, ok := m["metadata"].(map[string]interface{})["namespace"]; ok {
|
||||
return namespace.(string), nil
|
||||
}
|
||||
kind, ok := m["kind"].(string)
|
||||
if !ok {
|
||||
return "", errors.New("invalid kubernetes manifest, missing 'kind' field")
|
||||
}
|
||||
|
||||
if _, ok := m["metadata"]; ok {
|
||||
var namespace interface{}
|
||||
var ok bool
|
||||
if strings.EqualFold(kind, "namespace") {
|
||||
namespace, ok = m["metadata"].(map[string]interface{})["name"]
|
||||
} else {
|
||||
namespace, ok = m["metadata"].(map[string]interface{})["namespace"]
|
||||
}
|
||||
|
||||
if ok {
|
||||
if v, ok := namespace.(string); ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", errors.New("invalid kubernetes manifest, 'namespace' field is not a string")
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -648,7 +648,7 @@ func Test_GetNamespace(t *testing.T) {
|
||||
input: `apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
namespace: test-namespace
|
||||
name: test-namespace
|
||||
`,
|
||||
want: "test-namespace",
|
||||
},
|
||||
|
||||
44
api/pendingactions/actions/converters.go
Normal file
44
api/pendingactions/actions/converters.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type (
|
||||
CleanNAPWithOverridePoliciesPayload struct {
|
||||
EndpointGroupID portainer.EndpointGroupID
|
||||
}
|
||||
)
|
||||
|
||||
func ConvertCleanNAPWithOverridePoliciesPayload(actionData interface{}) (*CleanNAPWithOverridePoliciesPayload, error) {
|
||||
var payload CleanNAPWithOverridePoliciesPayload
|
||||
|
||||
if actionData == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// backward compatible with old data format
|
||||
if endpointGroupId, ok := actionData.(float64); ok {
|
||||
payload.EndpointGroupID = portainer.EndpointGroupID(endpointGroupId)
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
data, ok := actionData.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to convert actionData to map[string]interface{}")
|
||||
|
||||
}
|
||||
|
||||
for key, value := range data {
|
||||
switch key {
|
||||
case "EndpointGroupID":
|
||||
if endpointGroupID, ok := value.(float64); ok {
|
||||
payload.EndpointGroupID = portainer.EndpointGroupID(endpointGroupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &payload, nil
|
||||
}
|
||||
@@ -117,12 +117,18 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
|
||||
|
||||
switch pendingAction.Action {
|
||||
case actions.CleanNAPWithOverridePolicies:
|
||||
if (pendingAction.ActionData == nil) || (pendingAction.ActionData.(portainer.EndpointGroupID) == 0) {
|
||||
pendingActionData, err := actions.ConvertCleanNAPWithOverridePoliciesPayload(pendingAction.ActionData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse pendingActionData for CleanNAPWithOverridePoliciesPayload")
|
||||
}
|
||||
|
||||
if pendingActionData == nil || pendingActionData.EndpointGroupID == 0 {
|
||||
service.authorizationService.CleanNAPWithOverridePolicies(service.dataStore, endpoint, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
endpointGroupID := pendingAction.ActionData.(portainer.EndpointGroupID)
|
||||
endpointGroupID := pendingActionData.EndpointGroupID
|
||||
|
||||
endpointGroup, err := service.dataStore.EndpointGroup().Read(portainer.EndpointGroupID(endpointGroupID))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error reading environment group to clean NAP with override policies for environment %d and environment group %d", endpoint.ID, endpointGroup.ID)
|
||||
|
||||
@@ -1599,7 +1599,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.20.2"
|
||||
APIVersion = "2.20.3"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
|
||||
@@ -95,7 +95,7 @@ class KubernetesApplicationsController {
|
||||
} else {
|
||||
await this.KubernetesApplicationService.delete(application);
|
||||
|
||||
if (application.Metadata.labels[KubernetesPortainerApplicationStackNameLabel]) {
|
||||
if (application.Metadata.labels && application.Metadata.labels[KubernetesPortainerApplicationStackNameLabel]) {
|
||||
// Update applications in stack
|
||||
const stack = this.state.stacks.find((x) => x.Name === application.StackName);
|
||||
const index = stack.Applications.indexOf(application);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'history'"></pr-icon>
|
||||
</div>
|
||||
Activity Logs
|
||||
Activity logs
|
||||
</div>
|
||||
<div class="vertical-center">
|
||||
<datatable-searchbar on-change="($ctrl.onChangeKeyword)" value="$ctrl.keyword"></datatable-searchbar>
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
<page-header title="'User Activity'" breadcrumbs="['Activity Logs']" reload="true"> </page-header>
|
||||
<page-header title="'User activity logs'" breadcrumbs="['User activity logs']" reload="true"> </page-header>
|
||||
|
||||
<div class="be-indicator-container limited-be mx-4">
|
||||
<div>
|
||||
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<div class="limited-be-content">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date Range</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="text" class="form-control" disabled />
|
||||
<div class="mx-4">
|
||||
<div class="be-indicator-container limited-be">
|
||||
<div class="limited-be-link vertical-center m-4"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<div class="limited-be-content !p-0 !pt-[15px]">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date range</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="text" class="form-control" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
|
||||
Portainer user activity logs have a maximum retention of 7 days.
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled>
|
||||
<pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>
|
||||
Export as CSV
|
||||
</button>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
<div class="row mt-5">
|
||||
<activity-logs-datatable
|
||||
logs="$ctrl.state.logs"
|
||||
keyword="$ctrl.state.keyword"
|
||||
sort="$ctrl.state.sort"
|
||||
limit="$ctrl.state.limit"
|
||||
context-filter="$ctrl.state.contextFilter"
|
||||
total-items="$ctrl.state.totalItems"
|
||||
current-page="$ctrl.state.currentPage"
|
||||
feature="{{:: $ctrl.limitedFeature}}"
|
||||
on-change-keyword="($ctrl.onChangeKeyword)"
|
||||
on-change-sort="($ctrl.onChangeSort)"
|
||||
on-change-limit="($ctrl.onChangeLimit)"
|
||||
on-change-page="($ctrl.onChangePage)"
|
||||
></activity-logs-datatable>
|
||||
<p class="text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
|
||||
Portainer user activity logs have a maximum retention of 7 days.
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled>
|
||||
<pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>
|
||||
Export as CSV
|
||||
</button>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<activity-logs-datatable
|
||||
logs="$ctrl.state.logs"
|
||||
keyword="$ctrl.state.keyword"
|
||||
sort="$ctrl.state.sort"
|
||||
limit="$ctrl.state.limit"
|
||||
context-filter="$ctrl.state.contextFilter"
|
||||
total-items="$ctrl.state.totalItems"
|
||||
current-page="$ctrl.state.currentPage"
|
||||
feature="{{:: $ctrl.limitedFeature}}"
|
||||
on-change-keyword="($ctrl.onChangeKeyword)"
|
||||
on-change-sort="($ctrl.onChangeSort)"
|
||||
on-change-limit="($ctrl.onChangeLimit)"
|
||||
on-change-page="($ctrl.onChangePage)"
|
||||
></activity-logs-datatable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'history'"></pr-icon>
|
||||
</div>
|
||||
Authentication Events
|
||||
Authentication events
|
||||
</div>
|
||||
<div class="vertical-center">
|
||||
<datatable-searchbar on-change="($ctrl.onChangeKeyword)"></datatable-searchbar>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default class AuthLogsViewController {
|
||||
|
||||
this.limitedFeature = FeatureId.ACTIVITY_AUDIT;
|
||||
this.state = {
|
||||
keyword: 'f',
|
||||
keyword: '',
|
||||
date: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
|
||||
@@ -1,48 +1,55 @@
|
||||
<page-header title="'User Activity'" breadcrumbs="['User authentication activity']" reload="true"> </page-header>
|
||||
<page-header title="'User authentication logs'" breadcrumbs="['User authentication logs']" reload="true"> </page-header>
|
||||
|
||||
<div class="be-indicator-container limited-be mx-4">
|
||||
<div>
|
||||
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<div class="limited-be-content">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date Range</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="text" class="form-control" disabled />
|
||||
<div class="mx-4">
|
||||
<div class="be-indicator-container limited-be">
|
||||
<div class="limited-be-link vertical-center m-4"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<!-- 15px matches the padding for col-sm-12 for the widget and table -->
|
||||
<div class="limited-be-content !p-0 !pt-[15px]">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date range</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="text" class="form-control" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
|
||||
Portainer user authentication activity logs have a maximum retention of 7 days.
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled
|
||||
><pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>Export as CSV
|
||||
</button>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
<div class="row mt-5">
|
||||
<auth-logs-datatable
|
||||
logs="$ctrl.state.logs"
|
||||
keyword="$ctrl.state.keyword"
|
||||
sort="$ctrl.state.sort"
|
||||
limit="$ctrl.state.limit"
|
||||
context-filter="$ctrl.state.contextFilter"
|
||||
type-filter="$ctrl.state.typeFilter"
|
||||
total-items="$ctrl.state.totalItems"
|
||||
current-page="$ctrl.state.currentPage"
|
||||
feature="{{:: $ctrl.limitedFeature}}"
|
||||
on-change-context-filter="($ctrl.onChangeContextFilter)"
|
||||
on-change-type-filter="($ctrl.onChangeTypeFilter)"
|
||||
on-change-keyword="($ctrl.onChangeKeyword)"
|
||||
on-change-sort="($ctrl.onChangeSort)"
|
||||
on-change-limit="($ctrl.onChangeLimit)"
|
||||
on-change-page="($ctrl.onChangePage)"
|
||||
></auth-logs-datatable>
|
||||
<p class="text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
|
||||
Portainer user authentication logs have a maximum retention of 7 days.
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled
|
||||
><pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>Export as CSV
|
||||
</button>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<auth-logs-datatable
|
||||
logs="$ctrl.state.logs"
|
||||
keyword="$ctrl.state.keyword"
|
||||
sort="$ctrl.state.sort"
|
||||
limit="$ctrl.state.limit"
|
||||
context-filter="$ctrl.state.contextFilter"
|
||||
type-filter="$ctrl.state.typeFilter"
|
||||
total-items="$ctrl.state.totalItems"
|
||||
current-page="$ctrl.state.currentPage"
|
||||
feature="{{:: $ctrl.limitedFeature}}"
|
||||
on-change-context-filter="($ctrl.onChangeContextFilter)"
|
||||
on-change-type-filter="($ctrl.onChangeTypeFilter)"
|
||||
on-change-keyword="($ctrl.onChangeKeyword)"
|
||||
on-change-sort="($ctrl.onChangeSort)"
|
||||
on-change-limit="($ctrl.onChangeLimit)"
|
||||
on-change-page="($ctrl.onChangePage)"
|
||||
></auth-logs-datatable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,14 +5,38 @@ import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.ser
|
||||
|
||||
import { BEFeatureIndicator } from './BEFeatureIndicator';
|
||||
|
||||
type Variants = 'form-section' | 'widget' | 'multi-widget';
|
||||
|
||||
type OverlayClasses = {
|
||||
beLinkContainerClassName: string;
|
||||
contentClassName: string;
|
||||
};
|
||||
|
||||
const variantClassNames: Record<Variants, OverlayClasses> = {
|
||||
'form-section': {
|
||||
beLinkContainerClassName: '',
|
||||
contentClassName: '',
|
||||
},
|
||||
widget: {
|
||||
beLinkContainerClassName: '',
|
||||
// no padding so that the border overlaps the widget border
|
||||
contentClassName: '!p-0',
|
||||
},
|
||||
'multi-widget': {
|
||||
beLinkContainerClassName: 'm-4',
|
||||
// widgets have a mx of 15px and mb of 15px - match this at the top with padding
|
||||
contentClassName: '!p-0 !pt-[15px]',
|
||||
},
|
||||
};
|
||||
|
||||
export function BEOverlay({
|
||||
featureId,
|
||||
children,
|
||||
className,
|
||||
variant = 'form-section',
|
||||
}: {
|
||||
featureId: FeatureId;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: 'form-section' | 'widget' | 'multi-widget';
|
||||
}) {
|
||||
const isLimited = isLimitedToBE(featureId);
|
||||
if (!isLimited) {
|
||||
@@ -21,10 +45,22 @@ export function BEOverlay({
|
||||
|
||||
return (
|
||||
<div className="be-indicator-container limited-be">
|
||||
<div className="limited-be-link vertical-center">
|
||||
<div
|
||||
className={clsx(
|
||||
'limited-be-link vertical-center',
|
||||
variantClassNames[variant].beLinkContainerClassName
|
||||
)}
|
||||
>
|
||||
<BEFeatureIndicator featureId={featureId} />
|
||||
</div>
|
||||
<div className={clsx('limited-be-content', className)}>{children}</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'limited-be-content',
|
||||
variantClassNames[variant].contentClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { TextTip } from '@@/Tip/TextTip';
|
||||
import { HelpLink } from '@@/HelpLink';
|
||||
|
||||
import { useContainers } from '../queries/containers';
|
||||
import { useSystemLimits } from '../../proxy/queries/useInfo';
|
||||
import { useSystemLimits, useIsWindows } from '../../proxy/queries/useInfo';
|
||||
|
||||
import { useCreateOrReplaceMutation } from './useCreateMutation';
|
||||
import { useValidation } from './validation';
|
||||
@@ -48,6 +48,7 @@ export function CreateView() {
|
||||
function CreateForm() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const router = useRouter();
|
||||
const isWindows = useIsWindows(environmentId);
|
||||
const { trackEvent } = useAnalytics();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
const { authorized: isEnvironmentAdmin } = useIsEnvironmentAdmin({
|
||||
@@ -57,7 +58,8 @@ function CreateForm() {
|
||||
|
||||
const mutation = useCreateOrReplaceMutation();
|
||||
const initialValuesQuery = useInitialValues(
|
||||
mutation.isLoading || mutation.isSuccess
|
||||
mutation.isLoading || mutation.isSuccess,
|
||||
isWindows
|
||||
);
|
||||
const registriesQuery = useEnvironmentRegistries(environmentId);
|
||||
|
||||
@@ -84,9 +86,11 @@ function CreateForm() {
|
||||
|
||||
const environment = envQuery.data;
|
||||
|
||||
// if windows, hide capabilities. this is because capadd and capdel are not supported on windows
|
||||
const hideCapabilities =
|
||||
!environment.SecuritySettings.allowContainerCapabilitiesForRegularUsers &&
|
||||
!isEnvironmentAdmin;
|
||||
(!environment.SecuritySettings.allowContainerCapabilitiesForRegularUsers &&
|
||||
!isEnvironmentAdmin) ||
|
||||
isWindows;
|
||||
|
||||
const {
|
||||
isDuplicating = false,
|
||||
|
||||
@@ -5,9 +5,10 @@ import { DockerContainer } from '../../types';
|
||||
|
||||
import { CONTAINER_MODE, Values } from './types';
|
||||
|
||||
export function getDefaultViewModel() {
|
||||
export function getDefaultViewModel(isWindows: boolean) {
|
||||
const networkMode = isWindows ? 'nat' : 'bridge';
|
||||
return {
|
||||
networkMode: 'bridge',
|
||||
networkMode,
|
||||
hostname: '',
|
||||
domain: '',
|
||||
macAddress: '',
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface Values extends BaseFormValues {
|
||||
env: EnvVarValues;
|
||||
}
|
||||
|
||||
export function useInitialValues(submitting: boolean) {
|
||||
export function useInitialValues(submitting: boolean, isWindows: boolean) {
|
||||
const {
|
||||
params: { nodeName, from },
|
||||
} = useCurrentStateAndParams();
|
||||
@@ -66,9 +66,10 @@ export function useInitialValues(submitting: boolean) {
|
||||
|
||||
const networksQuery = useNetworksForSelector();
|
||||
|
||||
const fromContainerQuery = useContainer(environmentId, from, {
|
||||
const fromContainerQuery = useContainer(environmentId, from, nodeName, {
|
||||
enabled: !submitting,
|
||||
});
|
||||
|
||||
const runningContainersQuery = useContainers(environmentId, {
|
||||
enabled: !!from,
|
||||
});
|
||||
@@ -86,7 +87,7 @@ export function useInitialValues(submitting: boolean) {
|
||||
|
||||
if (!from) {
|
||||
return {
|
||||
initialValues: defaultValues(isPureAdmin, user.Id, nodeName),
|
||||
initialValues: defaultValues(isPureAdmin, user.Id, nodeName, isWindows),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,12 +152,13 @@ export function useInitialValues(submitting: boolean) {
|
||||
function defaultValues(
|
||||
isPureAdmin: boolean,
|
||||
currentUserId: UserId,
|
||||
nodeName: string
|
||||
nodeName: string,
|
||||
isWindows: boolean
|
||||
): Values {
|
||||
return {
|
||||
commands: commandsTabUtils.getDefaultViewModel(),
|
||||
volumes: volumesTabUtils.getDefaultViewModel(),
|
||||
network: networkTabUtils.getDefaultViewModel(),
|
||||
network: networkTabUtils.getDefaultViewModel(isWindows), // windows containers should default to the nat network, not the bridge
|
||||
labels: labelsTabUtils.getDefaultViewModel(),
|
||||
restartPolicy: restartPolicyTabUtils.getDefaultViewModel(),
|
||||
resources: resourcesTabUtils.getDefaultViewModel(),
|
||||
|
||||
@@ -8,10 +8,10 @@ import { Link } from '@@/Link';
|
||||
|
||||
export function LogView() {
|
||||
const {
|
||||
params: { endpointId: environmentId, id: containerId },
|
||||
params: { endpointId: environmentId, id: containerId, nodeName },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const containerQuery = useContainer(environmentId, containerId);
|
||||
const containerQuery = useContainer(environmentId, containerId, nodeName);
|
||||
if (!containerQuery.data || containerQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MountPoint,
|
||||
NetworkSettings,
|
||||
} from 'docker-types/generated/1.41';
|
||||
import { RawAxiosRequestHeaders } from 'axios';
|
||||
|
||||
import { PortainerResponse } from '@/react/docker/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
@@ -75,11 +76,15 @@ export interface ContainerJSON {
|
||||
export function useContainer(
|
||||
environmentId: EnvironmentId,
|
||||
containerId?: ContainerId,
|
||||
nodeName?: string,
|
||||
{ enabled }: { enabled?: boolean } = {}
|
||||
) {
|
||||
return useQuery(
|
||||
containerId ? queryKeys.container(environmentId, containerId) : [],
|
||||
() => (containerId ? getContainer(environmentId, containerId) : undefined),
|
||||
() =>
|
||||
containerId
|
||||
? getContainer(environmentId, containerId, nodeName)
|
||||
: undefined,
|
||||
{
|
||||
meta: {
|
||||
title: 'Failure',
|
||||
@@ -103,11 +108,19 @@ export type ContainerResponse = PortainerResponse<ContainerJSON>;
|
||||
|
||||
async function getContainer(
|
||||
environmentId: EnvironmentId,
|
||||
containerId: ContainerId
|
||||
containerId: ContainerId,
|
||||
nodeName?: string
|
||||
) {
|
||||
try {
|
||||
const headers: RawAxiosRequestHeaders = {};
|
||||
|
||||
if (nodeName) {
|
||||
headers['X-PortainerAgent-Target'] = nodeName;
|
||||
}
|
||||
|
||||
const { data } = await axios.get<ContainerResponse>(
|
||||
urlBuilder(environmentId, containerId, 'json')
|
||||
urlBuilder(environmentId, containerId, 'json'),
|
||||
{ headers }
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -81,7 +81,7 @@ function buildImageFullURIWithRegistry(image: string, registry: Registry) {
|
||||
}
|
||||
|
||||
function buildImageURIForGithub(image: string, registry: Registry) {
|
||||
const imageName = image.split('/').pop();
|
||||
const imageName = image.startsWith('/') ? image.slice(1) : image;
|
||||
|
||||
const namespace = registry.Github.UseOrganisation
|
||||
? registry.Github.OrganisationName
|
||||
|
||||
@@ -30,6 +30,12 @@ export function useInfo<TSelect = SystemInfo>(
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsWindows(environmentId: EnvironmentId) {
|
||||
const query = useInfo(environmentId, (info) => info.OSType === 'windows');
|
||||
|
||||
return !!query.data;
|
||||
}
|
||||
|
||||
export function useIsStandAlone(environmentId: EnvironmentId) {
|
||||
const query = useInfo(environmentId, (info) => !info.Swarm?.NodeID);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { confirm } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
import {
|
||||
useApplicationRevisionList,
|
||||
@@ -86,13 +87,16 @@ export function RollbackApplicationButton({
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sApplicationDetailsW">
|
||||
{isRollbackNotAvailable ? (
|
||||
<TooltipWithChildren message="Cannot roll back to previous configuration as none currently exists">
|
||||
<span>{rollbackButton}</span>
|
||||
</TooltipWithChildren>
|
||||
) : (
|
||||
rollbackButton
|
||||
)}
|
||||
<div className="flex gap-x-2">
|
||||
{isRollbackNotAvailable ? (
|
||||
<TooltipWithChildren message="Cannot roll back to previous configuration as none currently exists">
|
||||
<span>{rollbackButton}</span>
|
||||
</TooltipWithChildren>
|
||||
) : (
|
||||
rollbackButton
|
||||
)}
|
||||
<Tooltip message="Only one level of rollback is available, i.e. if you roll back from v2 to v1, and then roll back again, you will end up back at v2. Note that service changes and autoscaler rule changes are not included in rollback functionality. This is how Kubernetes works natively." />
|
||||
</div>
|
||||
</Authorized>
|
||||
);
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ function UnmatchedAffinitiesInfo({
|
||||
'datatable-highlighted': isHighlighted,
|
||||
'datatable-unhighlighted': !isHighlighted,
|
||||
})}
|
||||
key={aff.map((term) => term.key).join('')}
|
||||
>
|
||||
<td />
|
||||
<td colSpan={cellCount - 1}>
|
||||
|
||||
@@ -11,11 +11,13 @@ export const status = columnHelper.accessor('acceptsApplication', {
|
||||
cell: ({ getValue }) => {
|
||||
const acceptsApplication = getValue();
|
||||
return (
|
||||
<Icon
|
||||
icon={acceptsApplication ? Check : X}
|
||||
mode={acceptsApplication ? 'success' : 'danger'}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex items-center h-full">
|
||||
<Icon
|
||||
icon={acceptsApplication ? Check : X}
|
||||
mode={acceptsApplication ? 'success' : 'danger'}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { useMemo } from 'react';
|
||||
import { Pod, Taint, Node } from 'kubernetes-types/core/v1';
|
||||
import _ from 'lodash';
|
||||
import * as JsonPatch from 'fast-json-patch';
|
||||
|
||||
import { useNodesQuery } from '@/react/kubernetes/cluster/HomeView/nodes.service';
|
||||
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from '@/kubernetes/pod/models';
|
||||
|
||||
import {
|
||||
BasicTableSettings,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { useApplication, useApplicationPods } from '../../application.queries';
|
||||
import { NodePlacementRowData } from '../types';
|
||||
import { Affinity, Label, NodePlacementRowData } from '../types';
|
||||
|
||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
|
||||
|
||||
@@ -162,6 +162,68 @@ function computeTolerations(nodes: Node[], pod: Pod): NodePlacementRowData[] {
|
||||
return nodePlacements;
|
||||
}
|
||||
|
||||
function getUnmatchedNodeSelectorLabels(node: Node, pod: Pod): Label[] {
|
||||
const nodeLabels = node.metadata?.labels || {};
|
||||
const podNodeSelectorLabels = pod.spec?.nodeSelector || {};
|
||||
|
||||
return Object.entries(podNodeSelectorLabels)
|
||||
.filter(([key, value]) => nodeLabels[key] !== value)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
// Function to get unmatched required node affinities
|
||||
function getUnmatchedRequiredNodeAffinities(node: Node, pod: Pod): Affinity[] {
|
||||
const basicNodeAffinity =
|
||||
pod.spec?.affinity?.nodeAffinity
|
||||
?.requiredDuringSchedulingIgnoredDuringExecution;
|
||||
|
||||
const unmatchedRequiredNodeAffinities: Affinity[] =
|
||||
basicNodeAffinity?.nodeSelectorTerms.map(
|
||||
(selectorTerm) =>
|
||||
selectorTerm.matchExpressions?.flatMap((matchExpression) => {
|
||||
const exists = !!node.metadata?.labels?.[matchExpression.key];
|
||||
const isIn =
|
||||
exists &&
|
||||
_.includes(
|
||||
matchExpression.values,
|
||||
node.metadata?.labels?.[matchExpression.key]
|
||||
);
|
||||
|
||||
// Check if the match expression is satisfied
|
||||
if (
|
||||
(matchExpression.operator === 'Exists' && exists) ||
|
||||
(matchExpression.operator === 'DoesNotExist' && !exists) ||
|
||||
(matchExpression.operator === 'In' && isIn) ||
|
||||
(matchExpression.operator === 'NotIn' && !isIn) ||
|
||||
(matchExpression.operator === 'Gt' &&
|
||||
exists &&
|
||||
parseInt(node.metadata?.labels?.[matchExpression.key] || '', 10) >
|
||||
parseInt(matchExpression.values?.[0] || '', 10)) ||
|
||||
(matchExpression.operator === 'Lt' &&
|
||||
exists &&
|
||||
parseInt(node.metadata?.labels?.[matchExpression.key] || '', 10) <
|
||||
parseInt(matchExpression.values?.[0] || '', 10))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return the unmatched affinity
|
||||
return [
|
||||
{
|
||||
key: matchExpression.key,
|
||||
operator:
|
||||
matchExpression.operator as KubernetesPodNodeAffinityNodeSelectorRequirementOperators,
|
||||
values: matchExpression.values?.join(', ') || '',
|
||||
},
|
||||
];
|
||||
}) || []
|
||||
) || [];
|
||||
return unmatchedRequiredNodeAffinities;
|
||||
}
|
||||
|
||||
// Node requirement depending on the operator value
|
||||
// https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity
|
||||
function computeAffinities(
|
||||
@@ -173,76 +235,32 @@ function computeAffinities(
|
||||
(node, nodeIndex) => {
|
||||
let { acceptsApplication } = nodePlacements[nodeIndex];
|
||||
|
||||
if (pod.spec?.nodeSelector) {
|
||||
const patch = JsonPatch.compare(
|
||||
node.metadata?.labels || {},
|
||||
pod.spec.nodeSelector
|
||||
);
|
||||
_.remove(patch, { op: 'remove' });
|
||||
const unmatchedNodeSelectorLabels = patch.map((operation) => ({
|
||||
key: _.trimStart(operation.path, '/'),
|
||||
value: operation.op,
|
||||
}));
|
||||
if (unmatchedNodeSelectorLabels.length) {
|
||||
acceptsApplication = false;
|
||||
}
|
||||
// check node selectors for unmatched labels
|
||||
const unmatchedNodeSelectorLabels = getUnmatchedNodeSelectorLabels(
|
||||
node,
|
||||
pod
|
||||
);
|
||||
|
||||
// check node affinities that are required during scheduling
|
||||
const unmatchedRequiredNodeAffinities =
|
||||
getUnmatchedRequiredNodeAffinities(node, pod);
|
||||
|
||||
// If there are any unmatched affinities or node labels, the node does not accept the application
|
||||
if (
|
||||
unmatchedRequiredNodeAffinities.length ||
|
||||
unmatchedNodeSelectorLabels.length
|
||||
) {
|
||||
acceptsApplication = false;
|
||||
}
|
||||
|
||||
const basicNodeAffinity =
|
||||
pod.spec?.affinity?.nodeAffinity
|
||||
?.requiredDuringSchedulingIgnoredDuringExecution;
|
||||
if (basicNodeAffinity) {
|
||||
const unmatchedTerms = basicNodeAffinity.nodeSelectorTerms.map(
|
||||
(selectorTerm) => {
|
||||
const unmatchedExpressions = selectorTerm.matchExpressions?.flatMap(
|
||||
(matchExpression) => {
|
||||
const exists = {}.hasOwnProperty.call(
|
||||
node.metadata?.labels,
|
||||
matchExpression.key
|
||||
);
|
||||
const isIn =
|
||||
exists &&
|
||||
_.includes(
|
||||
matchExpression.values,
|
||||
node.metadata?.labels?.[matchExpression.key]
|
||||
);
|
||||
if (
|
||||
(matchExpression.operator === 'Exists' && exists) ||
|
||||
(matchExpression.operator === 'DoesNotExist' && !exists) ||
|
||||
(matchExpression.operator === 'In' && isIn) ||
|
||||
(matchExpression.operator === 'NotIn' && !isIn) ||
|
||||
(matchExpression.operator === 'Gt' &&
|
||||
exists &&
|
||||
parseInt(
|
||||
node.metadata?.labels?.[matchExpression.key] || '',
|
||||
10
|
||||
) > parseInt(matchExpression.values?.[0] || '', 10)) ||
|
||||
(matchExpression.operator === 'Lt' &&
|
||||
exists &&
|
||||
parseInt(
|
||||
node.metadata?.labels?.[matchExpression.key] || '',
|
||||
10
|
||||
) < parseInt(matchExpression.values?.[0] || '', 10))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [true];
|
||||
}
|
||||
);
|
||||
|
||||
return unmatchedExpressions;
|
||||
}
|
||||
);
|
||||
|
||||
_.remove(unmatchedTerms, (i) => !i);
|
||||
if (unmatchedTerms.length) {
|
||||
acceptsApplication = false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
const nodePlacementRowData: NodePlacementRowData = {
|
||||
...nodePlacements[nodeIndex],
|
||||
acceptsApplication,
|
||||
unmatchedNodeSelectorLabels,
|
||||
unmatchedNodeAffinities: unmatchedRequiredNodeAffinities,
|
||||
};
|
||||
|
||||
return nodePlacementRowData;
|
||||
}
|
||||
);
|
||||
return nodePlacementsFromAffinities;
|
||||
|
||||
@@ -77,14 +77,12 @@ export function ApplicationsStacksDatatable({
|
||||
namespaces={namespaces}
|
||||
value={namespace}
|
||||
onChange={onNamespaceChange}
|
||||
showSystem={tableState.showSystemResources}
|
||||
showSystem={showSystem}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<SystemResourceDescription
|
||||
showSystemResources={tableState.showSystemResources}
|
||||
/>
|
||||
<SystemResourceDescription showSystemResources={showSystem} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -7,13 +7,17 @@ import { InputGroup } from '@@/form-components/InputGroup';
|
||||
|
||||
import { Namespace } from './types';
|
||||
|
||||
function transformNamespaces(namespaces: Namespace[], showSystem: boolean) {
|
||||
return namespaces
|
||||
.filter((ns) => showSystem || !ns.IsSystem)
|
||||
.map(({ Name, IsSystem }) => ({
|
||||
label: IsSystem ? `${Name} - system` : Name,
|
||||
value: Name,
|
||||
}));
|
||||
function transformNamespaces(namespaces: Namespace[], showSystem?: boolean) {
|
||||
const transformedNamespaces = namespaces.map(({ Name, IsSystem }) => ({
|
||||
label: IsSystem ? `${Name} - system` : Name,
|
||||
value: Name,
|
||||
isSystem: IsSystem,
|
||||
}));
|
||||
if (showSystem === undefined) {
|
||||
return transformedNamespaces;
|
||||
}
|
||||
// only filter when showSystem is set
|
||||
return transformedNamespaces.filter((ns) => showSystem || !ns.isSystem);
|
||||
}
|
||||
|
||||
export function NamespaceFilter({
|
||||
@@ -25,19 +29,22 @@ export function NamespaceFilter({
|
||||
namespaces: Namespace[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
showSystem: boolean;
|
||||
showSystem?: boolean;
|
||||
}) {
|
||||
const transformedNamespaces = transformNamespaces(namespaces, showSystem);
|
||||
|
||||
// sync value with displayed namespaces
|
||||
useEffect(() => {
|
||||
const names = transformedNamespaces.map((ns) => ns.value);
|
||||
if (value && !names.find((ns) => ns === value)) {
|
||||
onChange(
|
||||
names.length > 0 ? names.find((ns) => ns === 'default') || names[0] : ''
|
||||
);
|
||||
const isSelectedNamespaceFound = names.some((ns) => ns === value);
|
||||
if (value && !isSelectedNamespaceFound) {
|
||||
const newNamespaceValue =
|
||||
names.length > 0
|
||||
? names.find((ns) => ns === 'default') || names[0]
|
||||
: '';
|
||||
onChange(newNamespaceValue);
|
||||
}
|
||||
}, [value, onChange, transformedNamespaces]);
|
||||
}, [value, onChange, transformedNamespaces, showSystem]);
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Authorized } from '@/react/hooks/useUser';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
interface Props {
|
||||
showSystemResources: boolean;
|
||||
showSystemResources?: boolean;
|
||||
}
|
||||
|
||||
export function SystemResourceDescription({ showSystemResources }: Props) {
|
||||
return !showSystemResources ? (
|
||||
return showSystemResources === false ? (
|
||||
<Authorized authorizations="K8sAccessSystemNamespaces" adminOnlyCE>
|
||||
<TextTip color="blue" className="!mb-0">
|
||||
System resources are hidden, this can be changed in the table settings
|
||||
|
||||
@@ -58,7 +58,10 @@ export function BackupS3Form() {
|
||||
validateOnMount
|
||||
>
|
||||
{({ values, errors, isSubmitting, setFieldValue, isValid }) => (
|
||||
<BEOverlay featureId={FeatureId.S3_BACKUP_SETTING}>
|
||||
<BEOverlay
|
||||
featureId={FeatureId.S3_BACKUP_SETTING}
|
||||
variant="form-section"
|
||||
>
|
||||
<Form className="form-horizontal">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
|
||||
@@ -30,7 +30,7 @@ export function HelmCertPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<BEOverlay featureId={FeatureId.CA_FILE} className="!p-0">
|
||||
<BEOverlay featureId={FeatureId.CA_FILE} variant="widget">
|
||||
<Widget>
|
||||
<Widget.Title
|
||||
icon={Key}
|
||||
|
||||
@@ -36,5 +36,9 @@ export async function getSystemVersion() {
|
||||
}
|
||||
|
||||
export function useSystemVersion() {
|
||||
return useQuery(queryKey, () => getSystemVersion());
|
||||
return useQuery(queryKey, () => getSystemVersion(), {
|
||||
// 24 hour stale time to reduce the number of requests to avoid github api rate limits
|
||||
// a hard refresh of the browser will still trigger a new request
|
||||
staleTime: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"author": "Portainer.io",
|
||||
"name": "portainer",
|
||||
"homepage": "http://portainer.io",
|
||||
"version": "2.20.2",
|
||||
"version": "2.20.3",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@github.com:portainer/portainer.git"
|
||||
|
||||
36
yarn.lock
36
yarn.lock
@@ -7314,11 +7314,6 @@ axios-cache-interceptor@^1.4.1:
|
||||
fast-defer "1.1.8"
|
||||
object-code "1.3.2"
|
||||
|
||||
axios-progress-bar@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/axios-progress-bar/-/axios-progress-bar-1.2.0.tgz#f9ee88dc9af977246be1ef07eedfa4c990c639c5"
|
||||
integrity sha512-PEgWb/b2SMyHnKJ/cxA46OdCuNeVlo8eqL0HxXPtz+6G/Jtpyo49icPbW+jpO1wUeDEjbqpseMoCyWxESxf5pA==
|
||||
|
||||
axios-progress-bar@portainer/progress-bar-4-axios:
|
||||
version "1.2.0"
|
||||
resolved "https://codeload.github.com/portainer/progress-bar-4-axios/tar.gz/d424cd29f5b629af9ffc5c3d6db8d92d11d82f0f"
|
||||
@@ -15830,7 +15825,16 @@ string-argv@0.3.2:
|
||||
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6"
|
||||
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -15948,7 +15952,14 @@ string_decoder@~1.1.1:
|
||||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -17459,7 +17470,7 @@ wordwrap@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
@@ -17477,6 +17488,15 @@ wrap-ansi@^6.2.0:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
|
||||
Reference in New Issue
Block a user