Compare commits

...

17 Commits

Author SHA1 Message Date
Ali
439714f93d fix(app): ensure placement errors surface per node [EE-7065] (#11821)
Co-authored-by: testa113 <testa113>
2024-05-14 15:03:15 +12:00
Oscar Zhou
2745e63527 fix(image): github registry image truncated [EE-7021] (#11767) 2024-05-10 09:01:42 +12:00
Oscar Zhou
24e0318280 fix(api): list docker volume performance [EE-6896] (#11754) 2024-05-09 13:02:42 +12:00
Matt Hook
9a079a83fa fix(pending-action): pending action data format [EE-7064] (#11793)
Co-authored-by: Prabhat Khera <91852476+prabhat-portainer@users.noreply.github.com>
2024-05-09 08:15:33 +12:00
Ali
1df6087c8e fix(auth logs): fix typo in search keyword [EE-6742] (#11791)
Co-authored-by: testa113 <testa113>
2024-05-08 09:16:02 +12:00
Ali
ae705bc245 fix(be-overlay): consistency overlay with variants [EE-6742] (#11775)
Co-authored-by: testa113 <testa113>
2024-05-07 16:16:52 +12:00
Ali
d725b5e3b6 fix(app): show one tooltip to describe rollback feature [EE-6825] (#11778)
Co-authored-by: testa113 <testa113>
2024-05-07 15:27:25 +12:00
cmeng
1b33b1f5dd fix(container): specify node name when get a container EE-6981 (#11750) 2024-05-07 11:34:37 +12:00
Steven Kang
b70f0fe3d2 fix: windows container capability [EE-5814] (#11765) 2024-05-03 10:56:38 +12:00
Ali
55ef46edb6 fix(namespace): wait for system ns setting to load before selecting existing ns [EE-6917] (#11709)
Co-authored-by: testa113 <testa113>
2024-05-02 16:43:08 +12:00
Prabhat Khera
c2654d55b3 fix(images): consider stopped containers for unused label [EE-6983] (#11630) 2024-05-02 14:35:28 +12:00
Prabhat Khera
7fab352dbf chore(version-bump): bump version to 2.20.3 [EE-7063] (#11756) 2024-05-02 14:33:41 +12:00
Matt Hook
0dcb5113f7 fix(kube): correctly extract namespace from namespace manifest [EE-6555] (#11674)
Co-authored-by: Prabhat Khera <prabhat.khera@portainer.io>
2024-05-02 14:28:01 +12:00
Ali
a1b0634d86 fix(kube): fix text in activity and authentication logs teasers [EE-6742] (#11746)
Co-authored-by: testa113 <testa113>
2024-05-02 14:23:47 +12:00
Ali
da134c3e3f fix(app): avoid 'no label' error when deleting external app [EE-6019] (#11697)
Co-authored-by: testa113 <testa113>
2024-05-02 14:22:12 +12:00
Ali
5191fc9220 fix(app): explain rollback tooltip [EE-6825] (#11699)
Co-authored-by: testa113 <testa113>
2024-05-02 14:10:40 +12:00
Ali
af4e362c5c fix(version): reduce github requests [EE-7017] (#11678) 2024-05-02 14:08:44 +12:00
46 changed files with 566 additions and 241 deletions

View File

@@ -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}.

View File

@@ -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")

View 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)
}
})
}

View File

@@ -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\"}"
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -85,7 +85,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.20.2
// @version 2.20.3
// @description.markdown api-description.md
// @termsOfService

View File

@@ -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
}

View File

@@ -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

View File

@@ -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{})

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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",
},

View 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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -10,7 +10,7 @@ export default class AuthLogsViewController {
this.limitedFeature = FeatureId.ACTIVITY_AUDIT;
this.state = {
keyword: 'f',
keyword: '',
date: {
from: 0,
to: 0,

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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: '',

View File

@@ -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(),

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -170,6 +170,7 @@ function UnmatchedAffinitiesInfo({
'datatable-highlighted': isHighlighted,
'datatable-unhighlighted': !isHighlighted,
})}
key={aff.map((term) => term.key).join('')}
>
<td />
<td colSpan={cellCount - 1}>

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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>
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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}

View File

@@ -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,
});
}

View File

@@ -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"

View File

@@ -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"