1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 05:19:39 +02:00

Compare commits

...

46 commits

Author SHA1 Message Date
Ali
eaa2be017d fix(helm): ensure the form is not 'dirty', when the values are unchanged [r8s-421] (#901) 2025-07-17 12:07:11 +12:00
James Player
4e4c5ffdb6 fix(app/kubernetes): Fix listing of secrets and configmaps with same name [r8s-288] (#897) 2025-07-16 16:37:59 +12:00
James Player
383bcc4113 fix(docker/images): Fix image detail actions icon colours [be-12044] (#892) 2025-07-15 13:57:43 +12:00
James Player
9f906b7417 refactor(app/tests): Make createMockUsers more deterministic [r8s-406] (#887) 2025-07-14 17:16:33 +12:00
Cara Ryan
db2e168540 chore: bump version to 2.32.0 (#884) 2025-07-14 10:23:05 +12:00
Ali
2697d6c5d7 feat(oci): oci helm support [r8s-361] (#787) 2025-07-13 10:37:43 +12:00
andres-portainer
b6a6ce9aaf fix(endpointedge): fix a deadlock in createAsyncEdgeAgentEndpoint() BE-12039 (#883) 2025-07-11 18:54:05 -03:00
Ali
89f6a94bd8 chore(select): show data-cy react select [r8s-402] (#881) 2025-07-11 20:06:41 +12:00
Steven Kang
96f2d69ae5 feat(observability): alerting experimental feature (#801)
Co-authored-by: JamesPlayer <james.player@portainer.io>
2025-07-11 16:55:23 +12:00
Cara Ryan
b7e906701a fix(kubernetes): Namespace access permission changes role bindings not created [R8S-366] (#826) 2025-07-11 14:55:48 +12:00
Steven Kang
150d986179 fix: CVE-2025-53547 (#880) 2025-07-11 13:57:21 +12:00
James Player
ef10ea2a7d fix(ui): Fixed TagsDatatable name column link (#847) 2025-07-11 11:01:37 +12:00
Viktor Pettersson
3bf84e8b0c fix(tags): reconcile edge relations prior to deletion [BE-11969] (#867) 2025-07-10 10:52:12 +12:00
andres-portainer
ea4b334c7e feat(csp): enable CSP by default BE-11961 (#872) 2025-07-09 16:15:43 -03:00
Oscar Zhou
4d11aa8655 fix(tag): ignore "environment not found" when deleting tag [BE-11944] (#869) 2025-07-09 09:55:59 -03:00
andres-portainer
302deb8299 chore(dataservices): enhance ReadAll() so it takes predicates for filtering results BE-12016 (#866) 2025-07-07 14:29:56 -03:00
Viktor Pettersson
0c80b1067d fix(styles): update datetime picker styles for improved dark mode support [BE-11672] (#863) 2025-07-07 20:54:44 +12:00
Steven Kang
0a36d4fbfd fix: kubectl sdk - capture fatal error and return instead of exiting 1 [r7s-371] (#841) 2025-07-07 11:29:29 +12:00
Oscar Zhou
c20a8b5a68 fix(template): app template v3 error [BE-11998] (#854) 2025-07-04 11:49:33 -03:00
Devon Steenberg
8ffe4e284a fix(tls): set insecureSkipVerify to false in FIPS mode [BE-11932] (#849) 2025-07-04 10:48:54 +12:00
Steven Kang
1332f718ae feat: add warning events count next to the status badge (#828) 2025-07-04 10:07:57 +12:00
James Player
f4df51884c fix(tests): Fix ServicesDatatable tests - r8s-395 (#860) 2025-07-03 16:01:08 +12:00
James Carppe
ce86129478 Updates for release 2.31.3 (#859) 2025-07-03 15:17:50 +12:00
andres-portainer
097b125e3a fix(boltdb): change some options to increase performance BE-12002 (#848) 2025-07-02 18:17:19 -03:00
andres-portainer
5c6b53922a feat(go): upgrade to Go v1.24.4 BE-11774 (#855) 2025-07-02 18:14:29 -03:00
James Carppe
e1b9f23f73 Updates for release 2.27.9 (#853) 2025-07-02 17:45:59 +12:00
LP B
e1c480d3c3 feat(app/edge-stacks): summarize the edge stack statuses in the backend (#818) 2025-07-01 15:04:10 +02:00
Steven Kang
363a62d885 fix: bump the docker binary version to v28.3.0 [r8s-390] (#837) 2025-07-01 20:10:39 +12:00
James Player
c6ee9a5a52 feat(ui): Rebranding - r8s-374 (#840) 2025-07-01 12:58:31 +12:00
andres-portainer
cf5990ccba fix(edgestackstatus): improve error handling BE-11963 (#844) 2025-06-30 20:54:16 -03:00
Oscar Zhou
b6f3682a62 refactor(edge): init endpoint relation when endpoint is created [BE-11928] (#814) 2025-06-30 15:15:56 -03:00
LP B
b43f864511 fix(api/endpoints): filter out waiting room environments for non admins (#810) 2025-06-30 15:35:51 +02:00
Oscar Zhou
0556ffb4a1 feat(csrf): add trusted origins cli flags [BE-11972] (#836) 2025-06-27 17:41:10 -03:00
Ali
303047656e fix(k8s-services): avoid rerendering services table [r8s-387] (#832) 2025-06-27 22:48:40 +12:00
Steven Kang
8d29b5ae71 fix: kubeconfig download button inconsistency between http and https (#829) 2025-06-27 09:38:04 +12:00
James Carppe
7d7ae24351 Updates for release 2.31.2 (#834) 2025-06-26 15:41:23 +12:00
James Carppe
97838e614d Updates for release 2.27.8 (#827) 2025-06-25 17:11:58 +12:00
Steven Kang
c897baad20 fix: fetching values from both install and upgrade views - develop [R8S-368] (#820) 2025-06-24 15:46:10 +12:00
andres-portainer
d51e9205d9 fix(endpointrelation): use a read-write transaction for mutations BE-11964 (#819) 2025-06-20 20:03:35 -03:00
James Carppe
e051c86bb5 Updates for release 2.31.1 (#816) 2025-06-19 14:07:18 +12:00
Steven Kang
c2b48cd003 feat(k8s): CloudNativePG in applications list and details - [R8S-357] (#777) 2025-06-19 09:03:52 +12:00
James Carppe
a7009eb8d5 Update bug report template for 2.27.7 (#805) 2025-06-17 12:52:12 +12:00
andres-portainer
036b87b649 fix(middlewares): fix data race in WithEndpoint() BE-11949 (#803) 2025-06-16 12:56:51 -03:00
Steven Kang
f07a3b1875 security: cve-2025-22874 & cve-2025-22871 bump go to 1.23.10 (#798) 2025-06-12 17:30:53 +12:00
Yajith Dayarathna
6e89ccc0ae fix(api-documentation): swagger document genration error (#795) 2025-06-12 13:39:34 +12:00
James Carppe
cc67612432 Update bug report template for 2.31.0 (#793) 2025-06-12 13:26:25 +12:00
247 changed files with 9062 additions and 1983 deletions

View file

@ -94,6 +94,10 @@ body:
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.31.3'
- '2.31.2'
- '2.31.1'
- '2.31.0'
- '2.30.1'
- '2.30.0'
- '2.29.2'
@ -101,6 +105,9 @@ body:
- '2.29.0'
- '2.28.1'
- '2.28.0'
- '2.27.9'
- '2.27.8'
- '2.27.7'
- '2.27.6'
- '2.27.5'
- '2.27.4'

View file

@ -61,6 +61,8 @@ func CLIFlags() *portainer.CLIFlags {
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
KubectlShellImage: kingpin.Flag("kubectl-shell-image", "Kubectl shell image").Envar(portainer.KubectlShellImageEnvVar).Default(portainer.DefaultKubectlShellImage).String(),
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
}
}

View file

@ -52,6 +52,7 @@ import (
"github.com/portainer/portainer/pkg/libhelm"
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/portainer/portainer/pkg/validate"
"github.com/gofrs/uuid"
"github.com/rs/zerolog/log"
@ -330,6 +331,18 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags)
}
trustedOrigins := []string{}
if *flags.TrustedOrigins != "" {
// validate if the trusted origins are valid urls
for _, origin := range strings.Split(*flags.TrustedOrigins, ",") {
if !validate.IsTrustedOrigin(origin) {
log.Fatal().Str("trusted_origin", origin).Msg("invalid url for trusted origin. Please check the trusted origins flag.")
}
trustedOrigins = append(trustedOrigins, origin)
}
}
fileService := initFileService(*flags.Data)
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
if encryptionKey == nil {
@ -370,7 +383,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
gitService := git.NewService(shutdownCtx)
openAMTService := openamt.NewService()
// Setting insecureSkipVerify to true to preserve the old behaviour.
openAMTService := openamt.NewService(true)
cryptoService := &crypto.Service{}
@ -545,6 +559,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
Status: applicationStatus,
BindAddress: *flags.Addr,
BindAddressHTTPS: *flags.AddrHTTPS,
CSP: *flags.CSP,
HTTPEnabled: sslDBSettings.HTTPEnabled,
AssetsPath: *flags.Assets,
DataStore: dataStore,
@ -578,6 +593,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
PendingActionsService: pendingActionsService,
PlatformService: platformService,
PullLimitCheckDisabled: *flags.PullLimitCheckDisabled,
TrustedOrigins: trustedOrigins,
}
}

View file

@ -138,6 +138,8 @@ func (connection *DbConnection) Open() error {
db, err := bolt.Open(databasePath, 0600, &bolt.Options{
Timeout: 1 * time.Second,
InitialMmapSize: connection.InitialMmapSize,
FreelistType: bolt.FreelistMapType,
NoFreelistSync: true,
})
if err != nil {
return err

View file

@ -10,7 +10,7 @@ type BaseCRUD[T any, I constraints.Integer] interface {
Create(element *T) error
Read(ID I) (*T, error)
Exists(ID I) (bool, error)
ReadAll() ([]T, error)
ReadAll(predicates ...func(T) bool) ([]T, error)
Update(ID I, element *T) error
Delete(ID I) error
}
@ -56,12 +56,13 @@ func (service BaseDataService[T, I]) Exists(ID I) (bool, error) {
return exists, err
}
func (service BaseDataService[T, I]) ReadAll() ([]T, error) {
// ReadAll retrieves all the elements that satisfy all the provided predicates.
func (service BaseDataService[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) {
var collection = make([]T, 0)
return collection, service.Connection.ViewTx(func(tx portainer.Transaction) error {
var err error
collection, err = service.Tx(tx).ReadAll()
collection, err = service.Tx(tx).ReadAll(predicates...)
return err
})

View file

@ -0,0 +1,92 @@
package dataservices
import (
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/slicesx"
"github.com/stretchr/testify/require"
)
type testObject struct {
ID int
Value int
}
type mockConnection struct {
store map[int]testObject
portainer.Connection
}
func (m mockConnection) UpdateObject(bucket string, key []byte, value interface{}) error {
obj := value.(*testObject)
m.store[obj.ID] = *obj
return nil
}
func (m mockConnection) GetAll(bucketName string, obj any, appendFn func(o any) (any, error)) error {
for _, v := range m.store {
if _, err := appendFn(&v); err != nil {
return err
}
}
return nil
}
func (m mockConnection) UpdateTx(fn func(portainer.Transaction) error) error {
return fn(m)
}
func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error {
return fn(m)
}
func (m mockConnection) ConvertToKey(v int) []byte {
return []byte(strconv.Itoa(v))
}
func TestReadAll(t *testing.T) {
service := BaseDataService[testObject, int]{
Bucket: "testBucket",
Connection: mockConnection{store: make(map[int]testObject)},
}
data := []testObject{
{ID: 1, Value: 1},
{ID: 2, Value: 2},
{ID: 3, Value: 3},
{ID: 4, Value: 4},
{ID: 5, Value: 5},
}
for _, item := range data {
err := service.Update(item.ID, &item)
require.NoError(t, err)
}
// ReadAll without predicates
result, err := service.ReadAll()
require.NoError(t, err)
expected := append([]testObject{}, data...)
require.ElementsMatch(t, expected, result)
// ReadAll with predicates
hasLowID := func(obj testObject) bool { return obj.ID < 3 }
isEven := func(obj testObject) bool { return obj.Value%2 == 0 }
result, err = service.ReadAll(hasLowID, isEven)
require.NoError(t, err)
expected = slicesx.Filter(expected, hasLowID)
expected = slicesx.Filter(expected, isEven)
require.ElementsMatch(t, expected, result)
}

View file

@ -34,14 +34,33 @@ func (service BaseDataServiceTx[T, I]) Exists(ID I) (bool, error) {
return service.Tx.KeyExists(service.Bucket, identifier)
}
func (service BaseDataServiceTx[T, I]) ReadAll() ([]T, error) {
// ReadAll retrieves all the elements that satisfy all the provided predicates.
func (service BaseDataServiceTx[T, I]) ReadAll(predicates ...func(T) bool) ([]T, error) {
var collection = make([]T, 0)
if len(predicates) == 0 {
return collection, service.Tx.GetAll(
service.Bucket,
new(T),
AppendFn(&collection),
)
}
filterFn := func(element T) bool {
for _, p := range predicates {
if !p(element) {
return false
}
}
return true
}
return collection, service.Tx.GetAll(
service.Bucket,
new(T),
FilterFn(&collection, filterFn),
)
}
func (service BaseDataServiceTx[T, I]) Update(ID I, element *T) error {

View file

@ -112,13 +112,13 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
}
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
return service.connection.ViewTx(func(tx portainer.Transaction) error {
return service.connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
})
}
func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
return service.connection.ViewTx(func(tx portainer.Transaction) error {
return service.connection.UpdateTx(func(tx portainer.Transaction) error {
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
})
}

View file

@ -0,0 +1,33 @@
package migrator
import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
perrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/internal/endpointutils"
)
func (m *Migrator) addEndpointRelationForEdgeAgents_2_32_0() error {
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
if endpointutils.IsEdgeEndpoint(&endpoint) {
_, err := m.endpointRelationService.EndpointRelation(endpoint.ID)
if err != nil && errors.Is(err, perrors.ErrObjectNotFound) {
relation := &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
if err := m.endpointRelationService.Create(relation); err != nil {
return err
}
}
}
}
return nil
}

View file

@ -249,6 +249,8 @@ func (m *Migrator) initMigrations() {
m.addMigrations("2.31.0", m.migrateEdgeStacksStatuses_2_31_0)
m.addMigrations("2.32.0", m.addEndpointRelationForEdgeAgents_2_32_0)
// Add new migrations above...
// One function per migration, each versions migration funcs in the same file.
}

View file

@ -121,6 +121,10 @@
"Ecr": {
"Region": ""
},
"Github": {
"OrganisationName": "",
"UseOrganisation": false
},
"Gitlab": {
"InstanceURL": "",
"ProjectId": 0,
@ -611,7 +615,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.31.0",
"KubectlShellImage": "portainer/kubectl-shell:2.32.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@ -776,6 +780,7 @@
"ImageCount": 9,
"IsPodman": false,
"NodeCount": 0,
"PerformanceMetrics": null,
"RunningContainerCount": 5,
"ServiceCount": 0,
"StackCount": 2,
@ -939,7 +944,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.31.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.32.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}

View file

@ -32,9 +32,9 @@ type Service struct {
}
// NewService initializes a new service.
func NewService() *Service {
func NewService(insecureSkipVerify bool) *Service {
tlsConfig := crypto.CreateTLSConfiguration()
tlsConfig.InsecureSkipVerify = true
tlsConfig.InsecureSkipVerify = insecureSkipVerify
return &Service{
httpsClient: &http.Client{

View file

@ -2,6 +2,7 @@ package csrf
import (
"crypto/rand"
"errors"
"fmt"
"net/http"
"os"
@ -9,7 +10,8 @@ import (
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
gorillacsrf "github.com/gorilla/csrf"
gcsrf "github.com/gorilla/csrf"
"github.com/rs/zerolog/log"
"github.com/urfave/negroni"
)
@ -19,7 +21,7 @@ func SkipCSRFToken(w http.ResponseWriter) {
w.Header().Set(csrfSkipHeader, "1")
}
func WithProtect(handler http.Handler) (http.Handler, error) {
func WithProtect(handler http.Handler, trustedOrigins []string) (http.Handler, error) {
// IsDockerDesktopExtension is used to check if we should skip csrf checks in the request bouncer (ShouldSkipCSRFCheck)
// DOCKER_EXTENSION is set to '1' in build/docker-extension/docker-compose.yml
isDockerDesktopExtension := false
@ -34,10 +36,12 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
}
handler = gorillacsrf.Protect(
handler = gcsrf.Protect(
token,
gorillacsrf.Path("/"),
gorillacsrf.Secure(false),
gcsrf.Path("/"),
gcsrf.Secure(false),
gcsrf.TrustedOrigins(trustedOrigins),
gcsrf.ErrorHandler(withErrorHandler(trustedOrigins)),
)(handler)
return withSkipCSRF(handler, isDockerDesktopExtension), nil
@ -55,7 +59,7 @@ func withSendCSRFToken(handler http.Handler) http.Handler {
}
if statusCode := sw.Status(); statusCode >= 200 && statusCode < 300 {
sw.Header().Set("X-CSRF-Token", gorillacsrf.Token(r))
sw.Header().Set("X-CSRF-Token", gcsrf.Token(r))
}
})
@ -73,9 +77,33 @@ func withSkipCSRF(handler http.Handler, isDockerDesktopExtension bool) http.Hand
}
if skip {
r = gorillacsrf.UnsafeSkipCheck(r)
r = gcsrf.UnsafeSkipCheck(r)
}
handler.ServeHTTP(w, r)
})
}
func withErrorHandler(trustedOrigins []string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := gcsrf.FailureReason(r)
if errors.Is(err, gcsrf.ErrBadOrigin) || errors.Is(err, gcsrf.ErrBadReferer) || errors.Is(err, gcsrf.ErrNoReferer) {
log.Error().Err(err).
Str("request_url", r.URL.String()).
Str("host", r.Host).
Str("x_forwarded_proto", r.Header.Get("X-Forwarded-Proto")).
Str("forwarded", r.Header.Get("Forwarded")).
Str("origin", r.Header.Get("Origin")).
Str("referer", r.Header.Get("Referer")).
Strs("trusted_origins", trustedOrigins).
Msg("Failed to validate Origin or Referer")
}
http.Error(
w,
http.StatusText(http.StatusForbidden)+" - "+err.Error(),
http.StatusForbidden,
)
})
}

View file

@ -2,6 +2,7 @@ package auth
import (
"net/http"
"strconv"
"strings"
portainer "github.com/portainer/portainer/api"
@ -82,6 +83,11 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
}
}
// Clear any existing user caches
if user != nil {
handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(user.ID)))
}
if user != nil && isUserInitialAdmin(user) || settings.AuthenticationMethod == portainer.AuthenticationInternal {
return handler.authenticateInternal(rw, user, payload.Password)
}

View file

@ -8,6 +8,7 @@ import (
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/gorilla/mux"
@ -23,16 +24,18 @@ type Handler struct {
OAuthService portainer.OAuthService
ProxyManager *proxy.Manager
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
KubernetesClientFactory *cli.ClientFactory
passwordStrengthChecker security.PasswordStrengthChecker
bouncer security.BouncerService
}
// NewHandler creates a handler to manage authentication operations.
func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker) *Handler {
func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimiter, passwordStrengthChecker security.PasswordStrengthChecker, kubernetesClientFactory *cli.ClientFactory) *Handler {
h := &Handler{
Router: mux.NewRouter(),
passwordStrengthChecker: passwordStrengthChecker,
bouncer: bouncer,
KubernetesClientFactory: kubernetesClientFactory,
}
h.Handle("/auth/oauth/validate",

View file

@ -2,6 +2,7 @@ package auth
import (
"net/http"
"strconv"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/logoutcontext"
@ -23,6 +24,7 @@ func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperro
if tokenData != nil {
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
handler.KubernetesClientFactory.ClearUserClientCache(strconv.Itoa(int(tokenData.ID)))
logoutcontext.Cancel(tokenData.Token)
}

View file

@ -71,7 +71,7 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
customTemplates = filterByType(customTemplates, templateTypes)
if edge != nil {
customTemplates = slicesx.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
return customTemplate.EdgeTemplate == *edge
})
}

View file

@ -163,7 +163,7 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request)
func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
if err != nil {
return err
}
@ -179,12 +179,6 @@ func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpoi
edgeStackSet[edgeStackID] = true
}
if relation == nil {
relation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
}
relation.EdgeStacks = edgeStackSet
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation)

View file

@ -3,10 +3,39 @@ package edgestacks
import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/slicesx"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
)
type aggregatedStatusesMap map[portainer.EdgeStackStatusType]int
type SummarizedStatus string
const (
sumStatusUnavailable SummarizedStatus = "Unavailable"
sumStatusDeploying SummarizedStatus = "Deploying"
sumStatusFailed SummarizedStatus = "Failed"
sumStatusPaused SummarizedStatus = "Paused"
sumStatusPartiallyRunning SummarizedStatus = "PartiallyRunning"
sumStatusCompleted SummarizedStatus = "Completed"
sumStatusRunning SummarizedStatus = "Running"
)
type edgeStackStatusSummary struct {
AggregatedStatus aggregatedStatusesMap
Status SummarizedStatus
Reason string
}
type edgeStackListResponseItem struct {
portainer.EdgeStack
StatusSummary edgeStackStatusSummary
}
// @id EdgeStackList
// @summary Fetches the list of EdgeStacks
// @description **Access policy**: administrator
@ -14,22 +43,122 @@ import (
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param summarizeStatuses query boolean false "will summarize the statuses"
// @success 200 {array} portainer.EdgeStack
// @failure 500
// @failure 400
// @failure 503 "Edge compute features are disabled"
// @router /edge_stacks [get]
func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
summarizeStatuses, _ := request.RetrieveBooleanQueryParameter(r, "summarizeStatuses", true)
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
if err != nil {
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
}
res := make([]edgeStackListResponseItem, len(edgeStacks))
for i := range edgeStacks {
if err := fillEdgeStackStatus(handler.DataStore, &edgeStacks[i]); err != nil {
res[i].EdgeStack = edgeStacks[i]
if summarizeStatuses {
if err := fillStatusSummary(handler.DataStore, &res[i]); err != nil {
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
}
} else if err := fillEdgeStackStatus(handler.DataStore, &res[i].EdgeStack); err != nil {
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
}
}
return response.JSON(w, edgeStacks)
return response.JSON(w, res)
}
func fillStatusSummary(tx dataservices.DataStoreTx, edgeStack *edgeStackListResponseItem) error {
statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID)
if err != nil {
return err
}
aggregated := make(aggregatedStatusesMap)
for _, envStatus := range statuses {
for _, status := range envStatus.Status {
aggregated[status.Type]++
}
}
status, reason := SummarizeStatuses(statuses, edgeStack.NumDeployments)
edgeStack.StatusSummary = edgeStackStatusSummary{
AggregatedStatus: aggregated,
Status: status,
Reason: reason,
}
edgeStack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{}
return nil
}
func SummarizeStatuses(statuses []portainer.EdgeStackStatusForEnv, numDeployments int) (SummarizedStatus, string) {
if numDeployments == 0 {
return sumStatusUnavailable, "Your edge stack is currently unavailable due to the absence of an available environment in your edge group"
}
allStatuses := slicesx.FlatMap(statuses, func(x portainer.EdgeStackStatusForEnv) []portainer.EdgeStackDeploymentStatus {
return x.Status
})
lastStatuses := slicesx.Map(
slicesx.Filter(
statuses,
func(s portainer.EdgeStackStatusForEnv) bool {
return len(s.Status) > 0
},
),
func(x portainer.EdgeStackStatusForEnv) portainer.EdgeStackDeploymentStatus {
return x.Status[len(x.Status)-1]
},
)
if len(lastStatuses) == 0 {
return sumStatusDeploying, ""
}
if allFailed := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
return s.Type == portainer.EdgeStackStatusError
}); allFailed {
return sumStatusFailed, ""
}
if hasPaused := slicesx.Some(allStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
return s.Type == portainer.EdgeStackStatusPausedDeploying
}); hasPaused {
return sumStatusPaused, ""
}
if len(lastStatuses) < numDeployments {
return sumStatusDeploying, ""
}
hasDeploying := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusDeploying })
hasRunning := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusRunning })
hasFailed := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusError })
if hasRunning && hasFailed && !hasDeploying {
return sumStatusPartiallyRunning, ""
}
if allCompleted := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusCompleted }); allCompleted {
return sumStatusCompleted, ""
}
if allRunning := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
return s.Type == portainer.EdgeStackStatusRunning
}); allRunning {
return sumStatusRunning, ""
}
return sumStatusDeploying, ""
}

View file

@ -133,7 +133,9 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack
}
environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID)
if err != nil {
if err != nil && !tx.IsErrObjectNotFound(err) {
return err
} else if tx.IsErrObjectNotFound(err) {
environmentStatus = &portainer.EdgeStackStatusForEnv{
EndpointID: payload.EndpointID,
Status: []portainer.EdgeStackDeploymentStatus{},

View file

@ -264,9 +264,6 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpointID p
func (handler *Handler) buildEdgeStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) ([]stackStatusResponse, *httperror.HandlerError) {
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
if err != nil {
if tx.IsErrObjectNotFound(err) {
return nil, nil
}
return nil, httperror.InternalServerError("Unable to retrieve relation object from the database", err)
}

View file

@ -21,17 +21,10 @@ func (handler *Handler) updateEndpointRelations(tx dataservices.DataStoreTx, end
}
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil && !tx.IsErrObjectNotFound(err) {
if err != nil {
return err
}
if endpointRelation == nil {
endpointRelation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
}
edgeGroups, err := tx.EdgeGroup().ReadAll()
if err != nil {
return err

View file

@ -563,6 +563,10 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(tx dataservices.Data
return err
}
if err := endpointutils.InitializeEdgeEndpointRelation(endpoint, tx); err != nil {
return err
}
for _, tagID := range endpoint.TagIDs {
if err := tx.Tag().UpdateTagFunc(tagID, func(tag *portainer.Tag) {
tag.Endpoints[endpoint.ID] = true

View file

@ -95,12 +95,11 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
return httperror.BadRequest("Invalid query parameters", err)
}
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(filteredEndpoints, query, endpointGroups, edgeGroups, settings)
filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(endpoints, query, endpointGroups, edgeGroups, settings, securityContext)
if err != nil {
return httperror.InternalServerError("Unable to filter endpoints", err)
}
filteredEndpoints = security.FilterEndpoints(filteredEndpoints, endpointGroups, securityContext)
sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc")

View file

@ -75,7 +75,7 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ
return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err)
}
registries, handleError := handler.filterRegistriesByAccess(r, registries, endpoint, user, securityContext.UserMemberships)
registries, handleError := handler.filterRegistriesByAccess(tx, r, registries, endpoint, user, securityContext.UserMemberships)
if handleError != nil {
return nil, handleError
}
@ -87,15 +87,15 @@ func (handler *Handler) listRegistries(tx dataservices.DataStoreTx, r *http.Requ
return registries, err
}
func (handler *Handler) filterRegistriesByAccess(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
func (handler *Handler) filterRegistriesByAccess(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
if !endpointutils.IsKubernetesEndpoint(endpoint) {
return security.FilterRegistries(registries, user, memberships, endpoint.ID), nil
}
return handler.filterKubernetesEndpointRegistries(r, registries, endpoint, user, memberships)
return handler.filterKubernetesEndpointRegistries(tx, r, registries, endpoint, user, memberships)
}
func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
func (handler *Handler) filterKubernetesEndpointRegistries(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User, memberships []portainer.TeamMembership) ([]portainer.Registry, *httperror.HandlerError) {
namespaceParam, _ := request.RetrieveQueryParameter(r, "namespace", true)
isAdmin, err := security.IsAdmin(r)
if err != nil {
@ -116,7 +116,7 @@ func (handler *Handler) filterKubernetesEndpointRegistries(r *http.Request, regi
return registries, nil
}
return handler.filterKubernetesRegistriesByUserRole(r, registries, endpoint, user)
return handler.filterKubernetesRegistriesByUserRole(tx, r, registries, endpoint, user)
}
func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) {
@ -169,7 +169,7 @@ func registryAccessPoliciesContainsNamespace(registryAccess portainer.RegistryAc
return false
}
func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) {
func (handler *Handler) filterKubernetesRegistriesByUserRole(tx dataservices.DataStoreTx, r *http.Request, registries []portainer.Registry, endpoint *portainer.Endpoint, user *portainer.User) ([]portainer.Registry, *httperror.HandlerError) {
err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if errors.Is(err, security.ErrAuthorizationRequired) {
return nil, httperror.Forbidden("User is not authorized", err)
@ -178,7 +178,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, re
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
}
userNamespaces, err := handler.userNamespaces(endpoint, user)
userNamespaces, err := handler.userNamespaces(tx, endpoint, user)
if err != nil {
return nil, httperror.InternalServerError("unable to retrieve user namespaces", err)
}
@ -186,7 +186,7 @@ func (handler *Handler) filterKubernetesRegistriesByUserRole(r *http.Request, re
return filterRegistriesByNamespaces(registries, endpoint.ID, userNamespaces), nil
}
func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) {
func (handler *Handler) userNamespaces(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, user *portainer.User) ([]string, error) {
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return nil, err
@ -197,7 +197,7 @@ func (handler *Handler) userNamespaces(endpoint *portainer.Endpoint, user *porta
return nil, err
}
userMemberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
userMemberships, err := tx.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil {
return nil, err
}

View file

@ -11,6 +11,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/handler/edgegroups"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/slicesx"
@ -140,6 +141,7 @@ func (handler *Handler) filterEndpointsByQuery(
groups []portainer.EndpointGroup,
edgeGroups []portainer.EdgeGroup,
settings *portainer.Settings,
context *security.RestrictedRequestContext,
) ([]portainer.Endpoint, int, error) {
totalAvailableEndpoints := len(filteredEndpoints)
@ -181,11 +183,16 @@ func (handler *Handler) filterEndpointsByQuery(
}
// filter edge environments by trusted/untrusted
// only portainer admins are allowed to see untrusted environments
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
if !endpointutils.IsEdgeEndpoint(&endpoint) {
return true
}
if query.edgeDeviceUntrusted {
return !endpoint.UserTrusted && context.IsAdmin
}
return endpoint.UserTrusted == !query.edgeDeviceUntrusted
})
@ -290,7 +297,9 @@ func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId port
n := 0
for _, envId := range envIds {
edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId)
if err != nil {
if dataservices.IsErrObjectNotFound(err) {
continue
} else if err != nil {
return nil, errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
}

View file

@ -6,6 +6,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/slicesx"
@ -263,6 +264,7 @@ func runTest(t *testing.T, test filterTest, handler *Handler, endpoints []portai
[]portainer.EndpointGroup{},
[]portainer.EdgeGroup{},
&portainer.Settings{},
&security.RestrictedRequestContext{IsAdmin: true},
)
is.NoError(err)

View file

@ -17,19 +17,9 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil {
if !tx.IsErrObjectNotFound(err) {
return errors.WithMessage(err, "Unable to retrieve environment relation inside the database")
}
relation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: map[portainer.EdgeStackID]bool{},
}
if err := tx.EndpointRelation().Create(relation); err != nil {
return errors.WithMessage(err, "Unable to create environment relation inside the database")
}
}
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
if err != nil {
return errors.WithMessage(err, "Unable to find environment group inside the database")

View file

@ -17,12 +17,12 @@ type Handler struct {
}
// NewHandler creates a handler to serve static files.
func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler {
func NewHandler(assetPublicPath string, csp bool, wasInstanceDisabled func() bool) *Handler {
h := &Handler{
Handler: security.MWSecureHeaders(
gzhttp.GzipHandler(http.FileServer(http.Dir(assetPublicPath))),
featureflags.IsEnabled("hsts"),
featureflags.IsEnabled("csp"),
csp,
),
wasInstanceDisabled: wasInstanceDisabled,
}
@ -36,6 +36,7 @@ func isHTML(acceptContent []string) bool {
return true
}
}
return false
}
@ -43,11 +44,13 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if handler.wasInstanceDisabled() {
if r.RequestURI == "/" || r.RequestURI == "/index.html" {
http.Redirect(w, r, "/timeout.html", http.StatusTemporaryRedirect)
return
}
} else {
if strings.HasPrefix(r.RequestURI, "/timeout.html") {
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
}

View file

@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.31.0
// @version 2.32.0
// @description.markdown api-description.md
// @termsOfService

View file

@ -2,8 +2,10 @@ package kubernetes
import (
"net/http"
"strconv"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/rs/zerolog/log"
@ -25,7 +27,13 @@ func (handler *Handler) prepareKubeClient(r *http.Request) (*cli.KubeClient, *ht
return nil, httperror.NotFound("Unable to find the Kubernetes endpoint associated to the request.", err)
}
pcli, err := handler.KubernetesClientFactory.GetPrivilegedKubeClient(endpoint)
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to retrieve token data associated to the request.")
return nil, httperror.InternalServerError("Unable to retrieve token data associated to the request.", err)
}
pcli, err := handler.KubernetesClientFactory.GetPrivilegedUserKubeClient(endpoint, strconv.Itoa(int(tokenData.ID)))
if err != nil {
log.Error().Err(err).Str("context", "prepareKubeClient").Msg("Unable to get a privileged Kubernetes client for the user.")
return nil, httperror.InternalServerError("Unable to get a privileged Kubernetes client for the user.", err)

View file

@ -20,7 +20,7 @@ import (
// @param id path int true "Environment identifier"
// @param namespace path string true "The namespace name the events are associated to"
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
// @success 200 {object} models.Event[] "Success"
// @success 200 {object} []kubernetes.K8sEvent "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
@ -68,7 +68,7 @@ func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r
// @produce json
// @param id path int true "Environment identifier"
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
// @success 200 {object} models.Event[] "Success"
// @success 200 {object} []kubernetes.K8sEvent "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."

View file

@ -146,7 +146,7 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (portainer.KubeClient, *ht
return nil, httperror.Forbidden(fmt.Sprintf("an error occurred during the getProxyKubeClient operation, permission denied to access the environment /api/kubernetes/%d. Error: ", endpointID), err)
}
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token)
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID)))
if !ok {
return nil, httperror.InternalServerError("an error occurred during the getProxyKubeClient operation,failed to get proxy KubeClient", nil)
}
@ -179,7 +179,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
}
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token)
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), strconv.Itoa(int(tokenData.ID)))
if ok {
next.ServeHTTP(w, r)
return
@ -269,7 +269,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return
}
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Token, kubeCli)
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), strconv.Itoa(int(tokenData.ID)), kubeCli)
next.ServeHTTP(w, r)
})
}

View file

@ -22,6 +22,7 @@ import (
// @produce json
// @param id path int true "Environment identifier"
// @param withResourceQuota query boolean true "When set to true, include the resource quota information as part of the Namespace information. Default is false"
// @param withUnhealthyEvents query boolean true "When set to true, include the unhealthy events information as part of the Namespace information. Default is false"
// @success 200 {array} portainer.K8sNamespaceInfo "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
@ -36,6 +37,12 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withResourceQuota. Error: ", err)
}
withUnhealthyEvents, err := request.RetrieveBooleanQueryParameter(r, "withUnhealthyEvents", true)
if err != nil {
log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Invalid query parameter withUnhealthyEvents")
return httperror.BadRequest("an error occurred during the GetKubernetesNamespaces operation, invalid query parameter withUnhealthyEvents. Error: ", err)
}
cli, httpErr := handler.prepareKubeClient(r)
if httpErr != nil {
log.Error().Err(httpErr).Str("context", "GetKubernetesNamespaces").Msg("Unable to get a Kubernetes client for the user")
@ -48,6 +55,14 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to retrieve namespaces from the Kubernetes cluster. Error: ", err)
}
if withUnhealthyEvents {
namespaces, err = cli.CombineNamespacesWithUnhealthyEvents(namespaces)
if err != nil {
log.Error().Err(err).Str("context", "GetKubernetesNamespaces").Msg("Unable to combine namespaces with unhealthy events")
return httperror.InternalServerError("an error occurred during the GetKubernetesNamespaces operation, unable to combine namespaces with unhealthy events. Error: ", err)
}
}
if withResourceQuota {
return cli.CombineNamespacesWithResourceQuotas(namespaces, w)
}

View file

@ -5,10 +5,10 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/internal/registryutils/access"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@ -17,6 +17,7 @@ import (
"github.com/gorilla/mux"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
func hideFields(registry *portainer.Registry, hideAccesses bool) {
@ -56,17 +57,20 @@ func newHandler(bouncer security.BouncerService) *Handler {
func (handler *Handler) initRouter(bouncer accessGuard) {
adminRouter := handler.NewRoute().Subrouter()
adminRouter.Use(bouncer.AdminAccess)
authenticatedRouter := handler.NewRoute().Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryList)).Methods(http.MethodGet)
adminRouter.Handle("/registries", httperror.LoggerHandler(handler.registryCreate)).Methods(http.MethodPost)
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryUpdate)).Methods(http.MethodPut)
adminRouter.Handle("/registries/{id}/configure", httperror.LoggerHandler(handler.registryConfigure)).Methods(http.MethodPost)
adminRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryDelete)).Methods(http.MethodDelete)
authenticatedRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet)
// Use registry-specific access bouncer for inspect and repositories endpoints
registryAccessRouter := handler.NewRoute().Subrouter()
registryAccessRouter.Use(bouncer.AuthenticatedAccess, handler.RegistryAccess)
registryAccessRouter.Handle("/registries/{id}", httperror.LoggerHandler(handler.registryInspect)).Methods(http.MethodGet)
// Keep the gitlab proxy on the regular authenticated router as it doesn't require specific registry access
authenticatedRouter := handler.NewRoute().Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry))
}
@ -88,9 +92,7 @@ func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Re
}
// this function validates that
//
// 1. user has the appropriate authorizations to perform the request
//
// 2. user has a direct or indirect access to the registry
func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
@ -98,11 +100,6 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain
return false, false, err
}
user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return false, false, err
}
// Portainer admins always have access to everything
if securityContext.IsAdmin {
return true, true, nil
@ -128,47 +125,68 @@ func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portain
return false, false, err
}
memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
// Use the enhanced registry access utility function that includes namespace validation
_, err = access.GetAccessibleRegistry(
handler.DataStore,
handler.K8sClientFactory,
securityContext.UserID,
endpointId,
registry.ID,
)
if err != nil {
return false, false, nil
return false, false, nil // No access
}
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces)
if endpointutils.IsKubernetesEndpoint(endpoint) {
kcl, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access")
}
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access")
}
authorizedNamespaces := registry.RegistryAccesses[endpointId].Namespaces
for _, namespace := range authorizedNamespaces {
// when the default namespace is authorized to use a registry, all users have the ability to use it
// unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations
if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
return true, false, nil
}
namespacePolicy := accessPolicies[namespace]
if security.AuthorizedAccess(user.ID, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) {
return true, false, nil
}
}
return false, false, nil
}
// validate access for docker environments
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access)
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams)
if security.AuthorizedRegistryAccess(registry, user, memberships, endpoint.ID) {
return true, false, nil
}
// when user has no access via their role, direct grant or indirect grant
// then they don't have access to the registry
return false, false, nil
}
// RegistryAccess defines a security check for registry-specific API endpoints.
// Authentication is required to access these endpoints.
// The user must have direct or indirect access to the specific registry being requested.
// This bouncer validates registry access using the userHasRegistryAccess logic.
func (handler *Handler) RegistryAccess(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// First ensure the user is authenticated
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteError(w, http.StatusUnauthorized, "Authentication required", httperrors.ErrUnauthorized)
return
}
// Extract registry ID from the route
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
httperror.WriteError(w, http.StatusBadRequest, "Invalid registry identifier route variable", err)
return
}
// Get the registry from the database
registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID))
if handler.DataStore.IsErrObjectNotFound(err) {
httperror.WriteError(w, http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err)
return
} else if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err)
return
}
// Check if user has access to this registry
hasAccess, _, err := handler.userHasRegistryAccess(r, registry)
if err != nil {
httperror.WriteError(w, http.StatusInternalServerError, "Unable to retrieve info from request context", err)
return
}
if !hasAccess {
log.Debug().
Int("registry_id", registryID).
Str("registry_name", registry.Name).
Int("user_id", int(tokenData.ID)).
Str("context", "RegistryAccessBouncer").
Msg("User access denied to registry")
httperror.WriteError(w, http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied)
return
}
next.ServeHTTP(w, r)
})
}

View file

@ -0,0 +1,89 @@
package registries
import (
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
)
func Test_RegistryAccess_RequiresAuthentication(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
registry := &portainer.Registry{
ID: 1,
Name: "test-registry",
URL: "https://registry.test.com",
}
err := store.Registry().Create(registry)
assert.NoError(t, err)
handler := &Handler{
DataStore: store,
}
req := httptest.NewRequest(http.MethodGet, "/registries/1", nil)
req = mux.SetURLVars(req, map[string]string{"id": "1"})
rr := httptest.NewRecorder()
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
bouncer := handler.RegistryAccess(testHandler)
bouncer.ServeHTTP(rr, req)
assert.Equal(t, http.StatusUnauthorized, rr.Code)
}
func Test_RegistryAccess_InvalidRegistryID(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole}
err := store.User().Create(user)
assert.NoError(t, err)
handler := &Handler{
DataStore: store,
}
req := httptest.NewRequest(http.MethodGet, "/registries/invalid", nil)
req = mux.SetURLVars(req, map[string]string{"id": "invalid"})
tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole}
req = req.WithContext(security.StoreTokenData(req, tokenData))
rr := httptest.NewRecorder()
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
bouncer := handler.RegistryAccess(testHandler)
bouncer.ServeHTTP(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func Test_RegistryAccess_RegistryNotFound(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true)
user := &portainer.User{ID: 1, Username: "test", Role: portainer.StandardUserRole}
err := store.User().Create(user)
assert.NoError(t, err)
handler := &Handler{
DataStore: store,
requestBouncer: testhelpers.NewTestRequestBouncer(),
}
req := httptest.NewRequest(http.MethodGet, "/registries/999", nil)
req = mux.SetURLVars(req, map[string]string{"id": "999"})
tokenData := &portainer.TokenData{ID: 1, Role: portainer.StandardUserRole}
req = req.WithContext(security.StoreTokenData(req, tokenData))
rr := httptest.NewRecorder()
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
bouncer := handler.RegistryAccess(testHandler)
bouncer.ServeHTTP(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
}

View file

@ -4,10 +4,12 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
// @id RegistryInspect
@ -31,6 +33,11 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid registry identifier route variable", err)
}
log.Debug().
Int("registry_id", registryID).
Str("context", "RegistryInspectHandler").
Msg("Starting registry inspection")
registry, err := handler.DataStore.Registry().Read(portainer.RegistryID(registryID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a registry with the specified identifier inside the database", err)
@ -38,14 +45,12 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err)
}
hasAccess, isAdmin, err := handler.userHasRegistryAccess(r, registry)
// Check if user is admin to determine if we should hide sensitive fields
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if !hasAccess {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
hideFields(registry, !isAdmin)
hideFields(registry, !securityContext.IsAdmin)
return response.JSON(w, registry)
}

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/edge"
"github.com/portainer/portainer/api/internal/endpointutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@ -58,6 +59,9 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
for endpointID := range tag.Endpoints {
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if tx.IsErrObjectNotFound(err) {
continue
}
if err != nil {
return httperror.InternalServerError("Unable to retrieve environment from the database", err)
}
@ -103,15 +107,10 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
}
for _, endpoint := range endpoints {
if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) {
err = updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks)
edgeJobs, err := tx.EdgeJob().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to update environment relations in the database", err)
return httperror.InternalServerError("Unable to retrieve edge job configurations from the database", err)
}
}
}
for _, edgeGroup := range edgeGroups {
edgeGroup.TagIDs = slices.DeleteFunc(edgeGroup.TagIDs, func(t portainer.TagID) bool {
return t == tagID
@ -123,6 +122,16 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
}
}
for _, endpoint := range endpoints {
if (!tag.Endpoints[endpoint.ID] && !tag.EndpointGroups[endpoint.GroupID]) || !endpointutils.IsEdgeEndpoint(&endpoint) {
continue
}
if err := updateEndpointRelations(tx, endpoint, edgeGroups, edgeStacks, edgeJobs); err != nil {
return httperror.InternalServerError("Unable to update environment relations in the database", err)
}
}
err = tx.Tag().Delete(tagID)
if err != nil {
return httperror.InternalServerError("Unable to remove the tag from the database", err)
@ -131,19 +140,12 @@ func deleteTag(tx dataservices.DataStoreTx, tagID portainer.TagID) error {
return nil
}
func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) error {
func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.Endpoint, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack, edgeJobs []portainer.EdgeJob) error {
endpointRelation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil && !tx.IsErrObjectNotFound(err) {
if err != nil {
return err
}
if endpointRelation == nil {
endpointRelation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
}
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
if err != nil {
return err
@ -157,5 +159,25 @@ func updateEndpointRelations(tx dataservices.DataStoreTx, endpoint portainer.End
endpointRelation.EdgeStacks = stacksSet
return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation)
if err := tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, endpointRelation); err != nil {
return err
}
for _, edgeJob := range edgeJobs {
endpoints, err := edge.GetEndpointsFromEdgeGroups(edgeJob.EdgeGroups, tx)
if err != nil {
return err
}
if slices.Contains(endpoints, endpoint.ID) {
continue
}
delete(edgeJob.GroupLogsCollection, endpoint.ID)
if err := tx.EdgeJob().Update(edgeJob.ID, &edgeJob); err != nil {
return err
}
}
return nil
}

View file

@ -1,6 +1,7 @@
package tags
import (
"github.com/portainer/portainer/api/dataservices"
"net/http"
"net/http/httptest"
"strconv"
@ -8,23 +9,18 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) {
const tagsCount = 100
_, store := datastore.MustNewTestStore(t, true, false)
user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole}
if err := store.User().Create(user); err != nil {
t.Fatal("could not create admin user:", err)
}
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handler, store := setUpHandler(t)
// Create all the tags and add them to the same edge group
var tagIDs []portainer.TagID
@ -84,3 +80,132 @@ func TestTagDeleteEdgeGroupsConcurrently(t *testing.T) {
t.Fatal("the edge group is not consistent")
}
}
func TestHandler_tagDelete(t *testing.T) {
t.Run("should delete tag and update related endpoints and edge groups", func(t *testing.T) {
handler, store := setUpHandler(t)
tag := &portainer.Tag{
ID: 1,
Name: "tag-1",
Endpoints: make(map[portainer.EndpointID]bool),
EndpointGroups: make(map[portainer.EndpointGroupID]bool),
}
require.NoError(t, store.Tag().Create(tag))
endpointGroup := &portainer.EndpointGroup{
ID: 2,
Name: "endpoint-group-1",
TagIDs: []portainer.TagID{tag.ID},
}
require.NoError(t, store.EndpointGroup().Create(endpointGroup))
endpoint1 := &portainer.Endpoint{
ID: 1,
Name: "endpoint-1",
GroupID: endpointGroup.ID,
}
require.NoError(t, store.Endpoint().Create(endpoint1))
endpoint2 := &portainer.Endpoint{
ID: 2,
Name: "endpoint-2",
TagIDs: []portainer.TagID{tag.ID},
}
require.NoError(t, store.Endpoint().Create(endpoint2))
tag.Endpoints[endpoint2.ID] = true
tag.EndpointGroups[endpointGroup.ID] = true
require.NoError(t, store.Tag().Update(tag.ID, tag))
dynamicEdgeGroup := &portainer.EdgeGroup{
ID: 1,
Name: "edgegroup-1",
TagIDs: []portainer.TagID{tag.ID},
Dynamic: true,
}
require.NoError(t, store.EdgeGroup().Create(dynamicEdgeGroup))
staticEdgeGroup := &portainer.EdgeGroup{
ID: 2,
Name: "edgegroup-2",
Endpoints: []portainer.EndpointID{endpoint2.ID},
}
require.NoError(t, store.EdgeGroup().Create(staticEdgeGroup))
req, err := http.NewRequest(http.MethodDelete, "/tags/"+strconv.Itoa(int(tag.ID)), nil)
if err != nil {
t.Fail()
return
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusNoContent, rec.Code)
// Check that the tag is deleted
_, err = store.Tag().Read(tag.ID)
require.ErrorIs(t, err, portainerDsErrors.ErrObjectNotFound)
// Check that the endpoints are updated
endpoint1, err = store.Endpoint().Endpoint(endpoint1.ID)
require.NoError(t, err)
assert.Len(t, endpoint1.TagIDs, 0, "endpoint-1 should not have any tags")
assert.Equal(t, endpoint1.GroupID, endpointGroup.ID, "endpoint-1 should still belong to the endpoint group")
endpoint2, err = store.Endpoint().Endpoint(endpoint2.ID)
require.NoError(t, err)
assert.Len(t, endpoint2.TagIDs, 0, "endpoint-2 should not have any tags")
// Check that the dynamic edge group is updated
dynamicEdgeGroup, err = store.EdgeGroup().Read(dynamicEdgeGroup.ID)
require.NoError(t, err)
assert.Len(t, dynamicEdgeGroup.TagIDs, 0, "dynamic edge group should not have any tags")
assert.Len(t, dynamicEdgeGroup.Endpoints, 0, "dynamic edge group should not have any endpoints")
// Check that the static edge group is not updated
staticEdgeGroup, err = store.EdgeGroup().Read(staticEdgeGroup.ID)
require.NoError(t, err)
assert.Len(t, staticEdgeGroup.TagIDs, 0, "static edge group should not have any tags")
assert.Len(t, staticEdgeGroup.Endpoints, 1, "static edge group should have one endpoint")
assert.Equal(t, endpoint2.ID, staticEdgeGroup.Endpoints[0], "static edge group should have the endpoint-2")
})
// Test the tx.IsErrObjectNotFound logic when endpoint is not found during cleanup
t.Run("should continue gracefully when endpoint not found during cleanup", func(t *testing.T) {
_, store := setUpHandler(t)
// Create a tag with a reference to a non-existent endpoint
tag := &portainer.Tag{
ID: 1,
Name: "test-tag",
Endpoints: map[portainer.EndpointID]bool{999: true}, // Non-existent endpoint
EndpointGroups: make(map[portainer.EndpointGroupID]bool),
}
err := store.Tag().Create(tag)
if err != nil {
t.Fatal("could not create tag:", err)
}
err = deleteTag(store, 1)
if err != nil {
t.Fatal("could not delete tag:", err)
}
})
}
func setUpHandler(t *testing.T) (*Handler, dataservices.DataStore) {
_, store := datastore.MustNewTestStore(t, true, false)
user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole}
if err := store.User().Create(user); err != nil {
t.Fatal("could not create admin user:", err)
}
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
return handler, store
}

View file

@ -40,11 +40,13 @@ func (handler *Handler) fetchTemplates() (*listResponse, *httperror.HandlerError
}
defer resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(&body)
if err != nil {
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, httperror.InternalServerError("Unable to parse template file", err)
}
for i := range body.Templates {
body.Templates[i].ID = portainer.TemplateID(i + 1)
}
return body, nil
}

View file

@ -80,7 +80,7 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
_, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, endpointID, payload.RegistryID)
_, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, endpointID, payload.RegistryID)
if err != nil {
return httperror.Forbidden("Permission deny to access registry", err)
}

View file

@ -69,7 +69,7 @@ func (handler *Handler) webhookUpdate(w http.ResponseWriter, r *http.Request) *h
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
_, err = access.GetAccessibleRegistry(handler.DataStore, tokenData.ID, webhook.EndpointID, payload.RegistryID)
_, err = access.GetAccessibleRegistry(handler.DataStore, nil, tokenData.ID, webhook.EndpointID, payload.RegistryID)
if err != nil {
return httperror.Forbidden("Permission deny to access registry", err)
}

View file

@ -25,12 +25,12 @@ type key int
const contextEndpoint key = 0
func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam string) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
if endpointIDParam == "" {
endpointIDParam = "id"
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
endpointID, err := requesthelpers.RetrieveNumericRouteVariableValue(request, endpointIDParam)
if err != nil {
httperror.WriteError(rw, http.StatusBadRequest, "Invalid environment identifier route variable", err)
@ -51,7 +51,6 @@ func WithEndpoint(endpointService dataservices.EndpointService, endpointIDParam
ctx := context.WithValue(request.Context(), contextEndpoint, endpoint)
next.ServeHTTP(rw, request.WithContext(ctx))
})
}
}

View file

@ -3,6 +3,7 @@ package middlewares
import (
"net/http"
"slices"
"strings"
"github.com/gorilla/csrf"
)
@ -16,6 +17,45 @@ type plainTextHTTPRequestHandler struct {
next http.Handler
}
// parseForwardedHeaderProto parses the Forwarded header and extracts the protocol.
// The Forwarded header format supports:
// - Single proxy: Forwarded: by=<identifier>;for=<identifier>;host=<host>;proto=<http|https>
// - Multiple proxies: Forwarded: for=192.0.2.43, for=198.51.100.17
// We take the first (leftmost) entry as it represents the original client
func parseForwardedHeaderProto(forwarded string) string {
if forwarded == "" {
return ""
}
// Parse the first part (leftmost proxy, closest to original client)
firstPart, _, _ := strings.Cut(forwarded, ",")
firstPart = strings.TrimSpace(firstPart)
// Split by semicolon to get key-value pairs within this proxy entry
// Format: key=value;key=value;key=value
pairs := strings.Split(firstPart, ";")
for _, pair := range pairs {
// Split by equals sign to separate key and value
key, value, found := strings.Cut(pair, "=")
if !found {
continue
}
if strings.EqualFold(strings.TrimSpace(key), "proto") {
return strings.Trim(strings.TrimSpace(value), `"'`)
}
}
return ""
}
// isHTTPSRequest checks if the original request was made over HTTPS
// by examining both X-Forwarded-Proto and Forwarded headers
func isHTTPSRequest(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") ||
strings.EqualFold(parseForwardedHeaderProto(r.Header.Get("Forwarded")), "https")
}
func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if slices.Contains(safeMethods, r.Method) {
h.next.ServeHTTP(w, r)
@ -24,7 +64,7 @@ func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.R
req := r
// If original request was HTTPS (via proxy), keep CSRF checks.
if xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" {
if !isHTTPSRequest(r) {
req = csrf.PlaintextHTTPRequest(r)
}

View file

@ -0,0 +1,173 @@
package middlewares
import (
"testing"
)
var tests = []struct {
name string
forwarded string
expected string
}{
{
name: "empty header",
forwarded: "",
expected: "",
},
{
name: "single proxy with proto=https",
forwarded: "proto=https",
expected: "https",
},
{
name: "single proxy with proto=http",
forwarded: "proto=http",
expected: "http",
},
{
name: "single proxy with multiple directives",
forwarded: "for=192.0.2.60;proto=https;by=203.0.113.43",
expected: "https",
},
{
name: "single proxy with proto in middle",
forwarded: "for=192.0.2.60;proto=https;host=example.com",
expected: "https",
},
{
name: "single proxy with proto at end",
forwarded: "for=192.0.2.60;host=example.com;proto=https",
expected: "https",
},
{
name: "multiple proxies - takes first",
forwarded: "proto=https, proto=http",
expected: "https",
},
{
name: "multiple proxies with complex format",
forwarded: "for=192.0.2.43;proto=https, for=198.51.100.17;proto=http",
expected: "https",
},
{
name: "multiple proxies with for directive only",
forwarded: "for=192.0.2.43, for=198.51.100.17",
expected: "",
},
{
name: "multiple proxies with proto only in second",
forwarded: "for=192.0.2.43, proto=https",
expected: "",
},
{
name: "multiple proxies with proto only in first",
forwarded: "proto=https, for=198.51.100.17",
expected: "https",
},
{
name: "quoted protocol value",
forwarded: "proto=\"https\"",
expected: "https",
},
{
name: "single quoted protocol value",
forwarded: "proto='https'",
expected: "https",
},
{
name: "mixed case protocol",
forwarded: "proto=HTTPS",
expected: "HTTPS",
},
{
name: "no proto directive",
forwarded: "for=192.0.2.60;by=203.0.113.43",
expected: "",
},
{
name: "empty proto value",
forwarded: "proto=",
expected: "",
},
{
name: "whitespace around values",
forwarded: " proto = https ",
expected: "https",
},
{
name: "whitespace around semicolons",
forwarded: "for=192.0.2.60 ; proto=https ; by=203.0.113.43",
expected: "https",
},
{
name: "whitespace around commas",
forwarded: "proto=https , proto=http",
expected: "https",
},
{
name: "IPv6 address in for directive",
forwarded: "for=\"[2001:db8:cafe::17]:4711\";proto=https",
expected: "https",
},
{
name: "complex multiple proxies with IPv6",
forwarded: "for=192.0.2.43;proto=https, for=\"[2001:db8:cafe::17]\";proto=http",
expected: "https",
},
{
name: "obfuscated identifiers",
forwarded: "for=_mdn;proto=https",
expected: "https",
},
{
name: "unknown identifier",
forwarded: "for=unknown;proto=https",
expected: "https",
},
{
name: "malformed key-value pair",
forwarded: "proto",
expected: "",
},
{
name: "malformed key-value pair with equals",
forwarded: "proto=",
expected: "",
},
{
name: "multiple equals signs",
forwarded: "proto=https=extra",
expected: "https=extra",
},
{
name: "mixed case directive name",
forwarded: "PROTO=https",
expected: "https",
},
{
name: "mixed case directive name with spaces",
forwarded: " Proto = https ",
expected: "https",
},
}
func TestParseForwardedHeaderProto(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseForwardedHeaderProto(tt.forwarded)
if result != tt.expected {
t.Errorf("parseForwardedHeader(%q) = %q, want %q", tt.forwarded, result, tt.expected)
}
})
}
}
func FuzzParseForwardedHeaderProto(f *testing.F) {
for _, t := range tests {
f.Add(t.forwarded)
}
f.Fuzz(func(t *testing.T, forwarded string) {
parseForwardedHeaderProto(forwarded)
})
}

View file

@ -38,14 +38,30 @@ type K8sApplication struct {
Labels map[string]string `json:"Labels,omitempty"`
Resource K8sApplicationResource `json:"Resource,omitempty"`
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"`
}
type Metadata struct {
Labels map[string]string `json:"labels"`
}
type CustomResourceMetadata struct {
Kind string `json:"kind"`
APIVersion string `json:"apiVersion"`
Plural string `json:"plural"`
}
type Pod struct {
Name string `json:"Name"`
ContainerName string `json:"ContainerName"`
Image string `json:"Image"`
ImagePullPolicy string `json:"ImagePullPolicy"`
Status string `json:"Status"`
NodeName string `json:"NodeName"`
PodIP string `json:"PodIP"`
UID string `json:"Uid"`
Resource K8sApplicationResource `json:"Resource,omitempty"`
CreationDate time.Time `json:"CreationDate"`
}
type Configuration struct {
@ -72,8 +88,8 @@ type TLSInfo struct {
// Existing types
type K8sApplicationResource struct {
CPURequest float64 `json:"CpuRequest"`
CPULimit float64 `json:"CpuLimit"`
MemoryRequest int64 `json:"MemoryRequest"`
MemoryLimit int64 `json:"MemoryLimit"`
CPURequest float64 `json:"CpuRequest,omitempty"`
CPULimit float64 `json:"CpuLimit,omitempty"`
MemoryRequest int64 `json:"MemoryRequest,omitempty"`
MemoryLimit int64 `json:"MemoryLimit,omitempty"`
}

View file

@ -35,7 +35,7 @@ type (
func getUniqueElements(items string) []string {
xs := strings.Split(items, ",")
xs = slicesx.Map(xs, strings.TrimSpace)
xs = slicesx.Filter(xs, func(x string) bool { return len(x) > 0 })
xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 })
return slicesx.Unique(xs)
}

View file

@ -55,12 +55,13 @@ func createRegistryAuthenticationHeader(
return
}
if err = registryutils.EnsureRegTokenValid(dataStore, matchingRegistry); err != nil {
if err = registryutils.PrepareRegistryCredentials(dataStore, matchingRegistry); err != nil {
return
}
authenticationHeader.Serveraddress = matchingRegistry.URL
authenticationHeader.Username, authenticationHeader.Password, err = registryutils.GetRegEffectiveCredential(matchingRegistry)
authenticationHeader.Username = matchingRegistry.Username
authenticationHeader.Password = matchingRegistry.Password
return
}

View file

@ -0,0 +1,108 @@
package github
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/segmentio/encoding/json"
"oras.land/oras-go/v2/registry/remote/retry"
)
const GitHubAPIHost = "https://api.github.com"
// Package represents a GitHub container package
type Package struct {
Name string `json:"name"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
}
// Client represents a GitHub API client
type Client struct {
httpClient *http.Client
baseURL string
}
// NewClient creates a new GitHub API client
func NewClient(token string) *Client {
return &Client{
httpClient: NewHTTPClient(token),
baseURL: GitHubAPIHost,
}
}
// GetContainerPackages fetches container packages for the configured namespace
// It's a small http client wrapper instead of using the github client because listing repositories is the only known operation that isn't directly supported by oras
func (c *Client) GetContainerPackages(ctx context.Context, useOrganisation bool, organisationName string) ([]string, error) {
// Determine the namespace (user or organisation) for the request
namespace := "user"
if useOrganisation {
namespace = "orgs/" + organisationName
}
// Build the full URL for listing container packages
url := fmt.Sprintf("%s/%s/packages?package_type=container", c.baseURL, namespace)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var packages []Package
if err := json.Unmarshal(body, &packages); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Extract repository names in the form "owner/name"
repositories := make([]string, len(packages))
for i, pkg := range packages {
repositories[i] = fmt.Sprintf("%s/%s", strings.ToLower(pkg.Owner.Login), strings.ToLower(pkg.Name))
}
return repositories, nil
}
// NewHTTPClient creates a new HTTP client configured for GitHub API requests
func NewHTTPClient(token string) *http.Client {
return &http.Client{
Transport: &tokenTransport{
token: token,
transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling
},
Timeout: 1 * time.Minute,
}
}
// tokenTransport automatically adds the Bearer token header to requests
type tokenTransport struct {
token string
transport http.RoundTripper
}
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.token != "" {
req.Header.Set("Authorization", "Bearer "+t.token)
req.Header.Set("Accept", "application/vnd.github+json")
}
return t.transport.RoundTrip(req)
}

View file

@ -0,0 +1,130 @@
package gitlab
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/segmentio/encoding/json"
"oras.land/oras-go/v2/registry/remote/retry"
)
// Repository represents a GitLab registry repository
type Repository struct {
ID int `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
ProjectID int `json:"project_id"`
Location string `json:"location"`
CreatedAt string `json:"created_at"`
Status string `json:"status"`
}
// Client represents a GitLab API client
type Client struct {
httpClient *http.Client
baseURL string
}
// NewClient creates a new GitLab API client
// it currently is an http client because only GetRegistryRepositoryNames is needed (oras supports other commands).
// if we need to support other commands, consider using the gitlab client library.
func NewClient(baseURL, token string) *Client {
return &Client{
httpClient: NewHTTPClient(token),
baseURL: baseURL,
}
}
// GetRegistryRepositoryNames fetches registry repository names for a given project.
// It's a small http client wrapper instead of using the gitlab client library because listing repositories is the only known operation that isn't directly supported by oras
func (c *Client) GetRegistryRepositoryNames(ctx context.Context, projectID int) ([]string, error) {
url := fmt.Sprintf("%s/api/v4/projects/%d/registry/repositories", c.baseURL, projectID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitLab API returned status %d: %s", resp.StatusCode, resp.Status)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var repositories []Repository
if err := json.Unmarshal(body, &repositories); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// Extract repository names
names := make([]string, len(repositories))
for i, repo := range repositories {
// the full path is required for further repo operations
names[i] = repo.Path
}
return names, nil
}
type Transport struct {
httpTransport *http.Transport
}
// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport
// interface for proxying requests to the Gitlab API.
func NewTransport() *Transport {
return &Transport{
httpTransport: &http.Transport{},
}
}
// RoundTrip is the implementation of the http.RoundTripper interface
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
token := request.Header.Get("Private-Token")
if token == "" {
return nil, errors.New("no gitlab token provided")
}
r, err := http.NewRequest(request.Method, request.URL.String(), request.Body)
if err != nil {
return nil, err
}
r.Header.Set("Private-Token", token)
return transport.httpTransport.RoundTrip(r)
}
// NewHTTPClient creates a new HTTP client configured for GitLab API requests
func NewHTTPClient(token string) *http.Client {
return &http.Client{
Transport: &tokenTransport{
token: token,
transport: retry.NewTransport(&http.Transport{}), // Use ORAS retry transport for consistent rate limiting and error handling
},
Timeout: 1 * time.Minute,
}
}
// tokenTransport automatically adds the Private-Token header to requests
type tokenTransport struct {
token string
transport http.RoundTripper
}
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Private-Token", t.token)
return t.transport.RoundTrip(req)
}

View file

@ -1,34 +0,0 @@
package gitlab
import (
"errors"
"net/http"
)
type Transport struct {
httpTransport *http.Transport
}
// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport
// interface for proxying requests to the Gitlab API.
func NewTransport() *Transport {
return &Transport{
httpTransport: &http.Transport{},
}
}
// RoundTrip is the implementation of the http.RoundTripper interface
func (transport *Transport) RoundTrip(request *http.Request) (*http.Response, error) {
token := request.Header.Get("Private-Token")
if token == "" {
return nil, errors.New("no gitlab token provided")
}
r, err := http.NewRequest(request.Method, request.URL.String(), request.Body)
if err != nil {
return nil, err
}
r.Header.Set("Private-Token", token)
return transport.httpTransport.RoundTrip(r)
}

View file

@ -58,6 +58,7 @@ func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*
switch {
case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"):
transport.k8sClientFactory.ClearClientCache()
defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID))
return transport.executeKubernetesRequest(request)
case strings.EqualFold(requestPath, "/namespaces"):

View file

@ -35,6 +35,7 @@ type (
JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
RevokeJWT(string)
DisableCSP()
}
// RequestBouncer represents an entity that manages API request accesses
@ -72,7 +73,7 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
jwtService: jwtService,
apiKeyService: apiKeyService,
hsts: featureflags.IsEnabled("hsts"),
csp: featureflags.IsEnabled("csp"),
csp: true,
}
go b.cleanUpExpiredJWT()
@ -80,6 +81,11 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
return b
}
// DisableCSP disables Content Security Policy
func (bouncer *RequestBouncer) DisableCSP() {
bouncer.csp = false
}
// PublicAccess defines a security check for public API endpoints.
// No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
@ -528,7 +534,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
}
if csp {
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud")
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud; frame-ancestors 'none';")
}
w.Header().Set("X-Content-Type-Options", "nosniff")

View file

@ -530,3 +530,34 @@ func TestJWTRevocation(t *testing.T) {
require.Equal(t, 1, revokeLen())
}
func TestCSPHeaderDefault(t *testing.T) {
b := NewRequestBouncer(nil, nil, nil)
srv := httptest.NewServer(
b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
)
defer srv.Close()
resp, err := http.Get(srv.URL + "/")
require.NoError(t, err)
defer resp.Body.Close()
require.Contains(t, resp.Header, "Content-Security-Policy")
}
func TestCSPHeaderDisabled(t *testing.T) {
b := NewRequestBouncer(nil, nil, nil)
b.DisableCSP()
srv := httptest.NewServer(
b.PublicAccess(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
)
defer srv.Close()
resp, err := http.Get(srv.URL + "/")
require.NoError(t, err)
defer resp.Body.Close()
require.NotContains(t, resp.Header, "Content-Security-Policy")
}

View file

@ -77,6 +77,7 @@ type Server struct {
AuthorizationService *authorization.Service
BindAddress string
BindAddressHTTPS string
CSP bool
HTTPEnabled bool
AssetsPath string
Status *portainer.Status
@ -113,6 +114,7 @@ type Server struct {
PendingActionsService *pendingactions.PendingActionsService
PlatformService platform.Service
PullLimitCheckDisabled bool
TrustedOrigins []string
}
// Start starts the HTTP server
@ -120,13 +122,16 @@ func (server *Server) Start() error {
kubernetesTokenCacheManager := server.KubernetesTokenCacheManager
requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService, server.APIKeyService)
if !server.CSP {
requestBouncer.DisableCSP()
}
rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour)
offlineGate := offlinegate.NewOfflineGate()
passwordStrengthChecker := security.NewPasswordStrengthChecker(server.DataStore.Settings())
var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker)
var authHandler = auth.NewHandler(requestBouncer, rateLimiter, passwordStrengthChecker, server.KubernetesClientFactory)
authHandler.DataStore = server.DataStore
authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService
@ -199,7 +204,7 @@ func (server *Server) Start() error {
var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory, containerService)
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), adminMonitor.WasInstanceDisabled)
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), server.CSP, adminMonitor.WasInstanceDisabled)
var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService)
@ -336,7 +341,7 @@ func (server *Server) Start() error {
handler = middlewares.WithPanicLogger(middlewares.WithSlowRequestsLogger(handler))
handler, err := csrf.WithProtect(handler)
handler, err := csrf.WithProtect(handler, server.TrustedOrigins)
if err != nil {
return errors.Wrap(err, "failed to create CSRF middleware")
}

View file

@ -129,9 +129,6 @@ func (service *Service) updateEndpointRelations(tx dataservices.DataStoreTx, edg
for _, endpointID := range relatedEndpointIds {
relation, err := endpointRelationService.EndpointRelation(endpointID)
if err != nil {
if tx.IsErrObjectNotFound(err) {
continue
}
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
}

View file

@ -249,3 +249,19 @@ func getEndpointCheckinInterval(endpoint *portainer.Endpoint, settings *portaine
return defaultInterval
}
func InitializeEdgeEndpointRelation(endpoint *portainer.Endpoint, tx dataservices.DataStoreTx) error {
if !IsEdgeEndpoint(endpoint) {
return nil
}
relation := &portainer.EndpointRelation{
EndpointID: endpoint.ID,
EdgeStacks: make(map[portainer.EdgeStackID]bool),
}
if err := tx.EndpointRelation().Create(relation); err != nil {
return err
}
return nil
}

View file

@ -2,40 +2,82 @@ package access
import (
"errors"
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
)
func hasPermission(
dataStore dataservices.DataStore,
k8sClientFactory *cli.ClientFactory,
userID portainer.UserID,
endpointID portainer.EndpointID,
registry *portainer.Registry,
) (hasPermission bool, err error) {
user, err := dataStore.User().Read(userID)
if err != nil {
return
return false, err
}
if user.Role == portainer.AdministratorRole {
return true, err
return true, nil
}
endpoint, err := dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
return false, err
}
teamMemberships, err := dataStore.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return
return false, err
}
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces)
if endpointutils.IsKubernetesEndpoint(endpoint) && k8sClientFactory != nil {
kcl, err := k8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
return false, fmt.Errorf("unable to retrieve kubernetes client to validate registry access: %w", err)
}
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return false, fmt.Errorf("unable to retrieve environment's namespaces policies to validate registry access: %w", err)
}
authorizedNamespaces := registry.RegistryAccesses[endpointID].Namespaces
for _, namespace := range authorizedNamespaces {
// when the default namespace is authorized to use a registry, all users have the ability to use it
// unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations
if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
return true, nil
}
namespacePolicy := accessPolicies[namespace]
if security.AuthorizedAccess(user.ID, teamMemberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) {
return true, nil
}
}
return false, nil
}
// validate access for docker environments
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access)
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams)
hasPermission = security.AuthorizedRegistryAccess(registry, user, teamMemberships, endpointID)
return
return hasPermission, nil
}
// GetAccessibleRegistry get the registry if the user has permission
func GetAccessibleRegistry(
dataStore dataservices.DataStore,
k8sClientFactory *cli.ClientFactory,
userID portainer.UserID,
endpointID portainer.EndpointID,
registryID portainer.RegistryID,
@ -46,7 +88,7 @@ func GetAccessibleRegistry(
return
}
hasPermission, err := hasPermission(dataStore, userID, endpointID, registry)
hasPermission, err := hasPermission(dataStore, k8sClientFactory, userID, endpointID, registry)
if err != nil {
return
}

View file

@ -62,3 +62,26 @@ func GetRegEffectiveCredential(registry *portainer.Registry) (username, password
return
}
// PrepareRegistryCredentials consolidates the common pattern of ensuring valid ECR token
// and setting effective credentials on the registry when authentication is enabled.
// This function modifies the registry in-place by setting Username and Password to the effective values.
func PrepareRegistryCredentials(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
if !registry.Authentication {
return nil
}
if err := EnsureRegTokenValid(tx, registry); err != nil {
return err
}
username, password, err := GetRegEffectiveCredential(registry)
if err != nil {
return err
}
registry.Username = username
registry.Password = password
return nil
}

View file

@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/api/database"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/slicesx"
)
var _ dataservices.DataStore = &testDatastore{}
@ -153,7 +154,16 @@ type stubUserService struct {
}
func (s *stubUserService) BucketName() string { return "users" }
func (s *stubUserService) ReadAll() ([]portainer.User, error) { return s.users, nil }
func (s *stubUserService) ReadAll(predicates ...func(portainer.User) bool) ([]portainer.User, error) {
filtered := s.users
for _, p := range predicates {
filtered = slicesx.Filter(filtered, p)
}
return filtered, nil
}
func (s *stubUserService) UsersByRole(role portainer.UserRole) ([]portainer.User, error) {
return s.users, nil
}
@ -172,7 +182,15 @@ type stubEdgeJobService struct {
}
func (s *stubEdgeJobService) BucketName() string { return "edgejobs" }
func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.jobs, nil }
func (s *stubEdgeJobService) ReadAll(predicates ...func(portainer.EdgeJob) bool) ([]portainer.EdgeJob, error) {
filtered := s.jobs
for _, p := range predicates {
filtered = slicesx.Filter(filtered, p)
}
return filtered, nil
}
// WithEdgeJobs option will instruct testDatastore to return provided jobs
func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
@ -362,8 +380,14 @@ func (s *stubStacksService) Read(ID portainer.StackID) (*portainer.Stack, error)
return nil, errors.ErrObjectNotFound
}
func (s *stubStacksService) ReadAll() ([]portainer.Stack, error) {
return s.stacks, nil
func (s *stubStacksService) ReadAll(predicates ...func(portainer.Stack) bool) ([]portainer.Stack, error) {
filtered := s.stacks
for _, p := range predicates {
filtered = slicesx.Filter(filtered, p)
}
return filtered, nil
}
func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID) ([]portainer.Stack, error) {

View file

@ -60,6 +60,8 @@ func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData,
func (testRequestBouncer) RevokeJWT(jti string) {}
func (testRequestBouncer) DisableCSP() {}
// AddTestSecurityCookie adds a security cookie to the request
func AddTestSecurityCookie(r *http.Request, jwt string) {
r.AddCookie(&http.Cookie{

View file

@ -77,9 +77,26 @@ func (factory *ClientFactory) ClearClientCache() {
factory.endpointProxyClients.Flush()
}
// ClearClientCache removes all cached kube clients for a userId
func (factory *ClientFactory) ClearUserClientCache(userID string) {
for key := range factory.endpointProxyClients.Items() {
if strings.HasSuffix(key, "."+userID) {
factory.endpointProxyClients.Delete(key)
}
}
}
// Remove the cached kube client so a new one can be created
func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) {
factory.endpointProxyClients.Delete(strconv.Itoa(int(endpointID)))
endpointPrefix := strconv.Itoa(int(endpointID)) + "."
for key := range factory.endpointProxyClients.Items() {
if strings.HasPrefix(key, endpointPrefix) {
factory.endpointProxyClients.Delete(key)
}
}
}
func (factory *ClientFactory) GetAddrHTTPS() string {
@ -104,6 +121,24 @@ func (factory *ClientFactory) GetPrivilegedKubeClient(endpoint *portainer.Endpoi
return kcl, nil
}
// GetPrivilegedUserKubeClient checks if an existing admin client is already registered for the environment(endpoint) and user and returns it if one is found.
// If no client is registered, it will create a new client, register it, and returns it.
func (factory *ClientFactory) GetPrivilegedUserKubeClient(endpoint *portainer.Endpoint, userID string) (*KubeClient, error) {
key := strconv.Itoa(int(endpoint.ID)) + ".admin." + userID
pcl, ok := factory.endpointProxyClients.Get(key)
if ok {
return pcl.(*KubeClient), nil
}
kcl, err := factory.createCachedPrivilegedKubeClient(endpoint)
if err != nil {
return nil, err
}
factory.endpointProxyClients.Set(key, kcl, cache.DefaultExpiration)
return kcl, nil
}
// GetProxyKubeClient retrieves a KubeClient from the cache. You should be
// calling SetProxyKubeClient before first. It is normally, called the
// kubernetes middleware.
@ -158,6 +193,7 @@ func (factory *ClientFactory) createCachedPrivilegedKubeClient(endpoint *portain
return &KubeClient{
cli: cli,
instanceID: factory.instanceID,
IsKubeAdmin: true,
}, nil
}

View file

@ -0,0 +1,22 @@
package cli
import (
"testing"
)
func TestClearUserClientCache(t *testing.T) {
factory, _ := NewClientFactory(nil, nil, nil, "", "", "")
kcl := &KubeClient{}
factory.endpointProxyClients.Set("12.1", kcl, 0)
factory.endpointProxyClients.Set("12.12", kcl, 0)
factory.endpointProxyClients.Set("12", kcl, 0)
factory.ClearUserClientCache("12")
if len(factory.endpointProxyClients.Items()) != 2 {
t.Errorf("Incorrect clients cached after clearUserClientCache;\ngot=\n%d\nwant=\n%d", len(factory.endpointProxyClients.Items()), 2)
}
if _, ok := factory.GetProxyKubeClient("12", "12"); ok {
t.Errorf("Expected not to find client cache for user after clear")
}
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"sort"
"strconv"
"time"
@ -351,6 +352,34 @@ func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace,
return namespace, nil
}
// CombineNamespacesWithUnhealthyEvents combines namespaces with unhealthy events across all namespaces
func (kcl *KubeClient) CombineNamespacesWithUnhealthyEvents(namespaces map[string]portainer.K8sNamespaceInfo) (map[string]portainer.K8sNamespaceInfo, error) {
allEvents, err := kcl.GetEvents("", "")
if err != nil && !k8serrors.IsNotFound(err) {
log.Error().
Str("context", "CombineNamespacesWithUnhealthyEvents").
Err(err).
Msg("unable to retrieve unhealthy events from the Kubernetes for an admin user")
return nil, err
}
unhealthyEventCounts := make(map[string]int)
for _, event := range allEvents {
if event.Type == "Warning" {
unhealthyEventCounts[event.Namespace]++
}
}
for namespaceName, namespace := range namespaces {
if count, exists := unhealthyEventCounts[namespaceName]; exists {
namespace.UnhealthyEventCount = count
namespaces[namespaceName] = namespace
}
}
return namespaces, nil
}
// CombineNamespacesWithResourceQuotas combines namespaces with resource quotas where matching is based on "portainer-rq-"+namespace.Name
func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError {
resourceQuotas, err := kcl.GetResourceQuotas("")
@ -409,5 +438,10 @@ func (kcl *KubeClient) ConvertNamespaceMapToSlice(namespaces map[string]portaine
namespaceSlice = append(namespaceSlice, namespace)
}
// Sort namespaces by name
sort.Slice(namespaceSlice, func(i, j int) bool {
return namespaceSlice[i].Name < namespaceSlice[j].Name
})
return namespaceSlice
}

View file

@ -110,6 +110,7 @@ type (
AdminPassword *string
AdminPasswordFile *string
Assets *string
CSP *bool
Data *string
FeatureFlags *[]string
EnableEdgeComputeFeatures *bool
@ -139,6 +140,7 @@ type (
LogMode *string
KubectlShellImage *string
PullLimitCheckDisabled *bool
TrustedOrigins *string
}
// CustomTemplateVariableDefinition
@ -233,6 +235,14 @@ type (
GpuUseList []string `json:"GpuUseList"`
IsPodman bool `json:"IsPodman"`
DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"`
PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics"`
}
// PerformanceMetrics represents the performance metrics of a Docker, Swarm, Podman, and Kubernetes environments
PerformanceMetrics struct {
CPUUsage float64 `json:"CPUUsage,omitempty"`
MemoryUsage float64 `json:"MemoryUsage,omitempty"`
NetworkUsage float64 `json:"NetworkUsage,omitempty"`
}
// DockerContainerSnapshot is an extent of Docker's Container struct
@ -593,6 +603,12 @@ type (
ProjectPath string `json:"ProjectPath"`
}
// GithubRegistryData represents data required for Github registry to work
GithubRegistryData struct {
UseOrganisation bool `json:"UseOrganisation"`
OrganisationName string `json:"OrganisationName"`
}
HelmUserRepositoryID int
// HelmUserRepositories stores a Helm repository URL for the given user
@ -625,6 +641,7 @@ type (
Status corev1.NamespaceStatus `json:"Status"`
Annotations map[string]string `json:"Annotations"`
CreationDate string `json:"CreationDate"`
UnhealthyEventCount int `json:"UnhealthyEventCount"`
NamespaceOwner string `json:"NamespaceOwner"`
IsSystem bool `json:"IsSystem"`
IsDefault bool `json:"IsDefault"`
@ -666,6 +683,7 @@ type (
TotalCPU int64 `json:"TotalCPU"`
TotalMemory int64 `json:"TotalMemory"`
DiagnosticsData *DiagnosticsData `json:"DiagnosticsData"`
PerformanceMetrics *PerformanceMetrics `json:"PerformanceMetrics"`
}
// KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint)
@ -811,6 +829,7 @@ type (
Password string `json:"Password,omitempty" example:"registry_password"`
ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"`
Gitlab GitlabRegistryData `json:"Gitlab"`
Github GithubRegistryData `json:"Github"`
Quay QuayRegistryData `json:"Quay"`
Ecr EcrData `json:"Ecr"`
RegistryAccesses RegistryAccesses `json:"RegistryAccesses"`
@ -1728,7 +1747,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.31.0"
APIVersion = "2.32.0"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
@ -1787,6 +1806,10 @@ const (
LicenseServerBaseURL = "https://api.portainer.io"
// URL to validate licenses along with system metadata.
LicenseCheckInURL = LicenseServerBaseURL + "/licenses/checkin"
// TrustedOriginsEnvVar is the environment variable used to set the trusted origins for CSRF protection
TrustedOriginsEnvVar = "TRUSTED_ORIGINS"
// CSPEnvVar is the environment variable used to enable/disable the Content Security Policy
CSPEnvVar = "CSP"
)
// List of supported features
@ -1956,6 +1979,8 @@ const (
DockerHubRegistry
// EcrRegistry represents an ECR registry
EcrRegistry
// Github container registry
GithubRegistry
)
const (

28
api/slicesx/filter.go Normal file
View file

@ -0,0 +1,28 @@
package slicesx
// Iterates over elements of collection, returning an array of all elements predicate returns truthy for.
//
// Note: Unlike `FilterInPlace`, this method returns a new array.
func Filter[T any](input []T, predicate func(T) bool) []T {
result := make([]T, 0)
for i := range input {
if predicate(input[i]) {
result = append(result, input[i])
}
}
return result
}
// Filter in place all elements from input that predicate returns truthy for and returns an array of the removed elements.
//
// Note: Unlike `Filter`, this method mutates input.
func FilterInPlace[T any](input []T, predicate func(T) bool) []T {
n := 0
for _, v := range input {
if predicate(v) {
input[n] = v
n++
}
}
return input[:n]
}

View file

@ -0,0 +1,96 @@
package slicesx_test
import (
"testing"
"github.com/portainer/portainer/api/slicesx"
)
func Test_Filter(t *testing.T) {
test(t, slicesx.Filter, "Filter even numbers",
[]int{1, 2, 3, 4, 5, 6, 7, 8, 9},
[]int{2, 4, 6, 8},
func(x int) bool { return x%2 == 0 },
)
test(t, slicesx.Filter, "Filter odd numbers",
[]int{1, 2, 3, 4, 5, 6, 7, 8, 9},
[]int{1, 3, 5, 7, 9},
func(x int) bool { return x%2 == 1 },
)
test(t, slicesx.Filter, "Filter strings starting with 'A'",
[]string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
[]string{"Apple", "Avocado", "Apricot"},
func(s string) bool { return s[0] == 'A' },
)
test(t, slicesx.Filter, "Filter strings longer than 5 chars",
[]string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
[]string{"Banana", "Avocado", "Grapes", "Apricot"},
func(s string) bool { return len(s) > 5 },
)
}
func Test_Retain(t *testing.T) {
test(t, slicesx.FilterInPlace, "Filter even numbers",
[]int{1, 2, 3, 4, 5, 6, 7, 8, 9},
[]int{2, 4, 6, 8},
func(x int) bool { return x%2 == 0 },
)
test(t, slicesx.FilterInPlace, "Filter odd numbers",
[]int{1, 2, 3, 4, 5, 6, 7, 8, 9},
[]int{1, 3, 5, 7, 9},
func(x int) bool { return x%2 == 1 },
)
test(t, slicesx.FilterInPlace, "Filter strings starting with 'A'",
[]string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
[]string{"Apple", "Avocado", "Apricot"},
func(s string) bool { return s[0] == 'A' },
)
test(t, slicesx.FilterInPlace, "Filter strings longer than 5 chars",
[]string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
[]string{"Banana", "Avocado", "Grapes", "Apricot"},
func(s string) bool { return len(s) > 5 },
)
}
func Benchmark_Filter(b *testing.B) {
n := 100000
source := make([]int, n)
for i := range source {
source[i] = i
}
b.ResetTimer()
for range b.N {
e := slicesx.Filter(source, func(x int) bool { return x%2 == 0 })
if len(e) != n/2 {
b.FailNow()
}
}
}
func Benchmark_FilterInPlace(b *testing.B) {
n := 100000
source := make([]int, n)
for i := range source {
source[i] = i
}
// Preallocate all copies before timing
// because FilterInPlace mutates the original slice
copies := make([][]int, b.N)
for i := range b.N {
buf := make([]int, len(source))
copy(buf, source)
copies[i] = buf
}
b.ResetTimer()
for i := range b.N {
e := slicesx.FilterInPlace(copies[i], func(x int) bool { return x%2 == 0 })
if len(e) != n/2 {
b.FailNow()
}
}
}

7
api/slicesx/flatten.go Normal file
View file

@ -0,0 +1,7 @@
package slicesx
import "slices"
func Flatten[T any](input [][]T) []T {
return slices.Concat(input...)
}

View file

@ -0,0 +1,19 @@
package slicesx_test
import (
"testing"
"github.com/portainer/portainer/api/slicesx"
"github.com/stretchr/testify/assert"
)
func Test_Flatten(t *testing.T) {
t.Run("Flatten an array of arrays", func(t *testing.T) {
is := assert.New(t)
source := [][]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}
expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
is.ElementsMatch(slicesx.Flatten(source), expected)
})
}

17
api/slicesx/includes.go Normal file
View file

@ -0,0 +1,17 @@
package slicesx
import "slices"
// Checks if predicate returns truthy for any element of input. Iteration is stopped once predicate returns truthy.
func Some[T any](input []T, predicate func(T) bool) bool {
return slices.ContainsFunc(input, predicate)
}
// Checks if predicate returns truthy for all elements of input. Iteration is stopped once predicate returns falsey.
//
// Note: This method returns true for empty collections because everything is true of elements of empty collections.
// https://en.wikipedia.org/wiki/Vacuous_truth
func Every[T any](input []T, predicate func(T) bool) bool {
// if the slice doesn't contain an inverted predicate then all items follow the predicate
return !slices.ContainsFunc(input, func(t T) bool { return !predicate(t) })
}

View file

@ -0,0 +1,76 @@
package slicesx_test
import (
"testing"
"github.com/portainer/portainer/api/slicesx"
)
func Test_Every(t *testing.T) {
test(t, slicesx.Every, "All start with an A (ok)",
[]string{"Apple", "Avocado", "Apricot"},
true,
func(s string) bool { return s[0] == 'A' },
)
test(t, slicesx.Every, "All start with an A (ko = some don't start with A)",
[]string{"Apple", "Avocado", "Banana"},
false,
func(s string) bool { return s[0] == 'A' },
)
test(t, slicesx.Every, "All are under 5 (ok)",
[]int{1, 2, 3},
true,
func(i int) bool { return i < 5 },
)
test(t, slicesx.Every, "All are under 5 (ko = some above 10)",
[]int{1, 2, 10},
false,
func(i int) bool { return i < 5 },
)
test(t, slicesx.Every, "All are true (ok)",
[]struct{ x bool }{{x: true}, {x: true}, {x: true}},
true,
func(s struct{ x bool }) bool { return s.x })
test(t, slicesx.Every, "All are true (ko = some are false)",
[]struct{ x bool }{{x: true}, {x: true}, {x: false}},
false,
func(s struct{ x bool }) bool { return s.x })
test(t, slicesx.Every, "Must be true on empty slice",
[]int{},
true,
func(i int) bool { return i%2 == 0 },
)
}
func Test_Some(t *testing.T) {
test(t, slicesx.Some, "Some start with an A (ok)",
[]string{"Apple", "Avocado", "Banana"},
true,
func(s string) bool { return s[0] == 'A' },
)
test(t, slicesx.Some, "Some start with an A (ko = all don't start with A)",
[]string{"Banana", "Cherry", "Peach"},
false,
func(s string) bool { return s[0] == 'A' },
)
test(t, slicesx.Some, "Some are under 5 (ok)",
[]int{1, 2, 30},
true,
func(i int) bool { return i < 5 },
)
test(t, slicesx.Some, "Some are under 5 (ko = all above 5)",
[]int{10, 11, 12},
false,
func(i int) bool { return i < 5 },
)
test(t, slicesx.Some, "Some are true (ok)",
[]struct{ x bool }{{x: true}, {x: true}, {x: false}},
true,
func(s struct{ x bool }) bool { return s.x },
)
test(t, slicesx.Some, "Some are true (ko = all are false)",
[]struct{ x bool }{{x: false}, {x: false}, {x: false}},
false,
func(s struct{ x bool }) bool { return s.x },
)
}

15
api/slicesx/map.go Normal file
View file

@ -0,0 +1,15 @@
package slicesx
// Map applies the given function to each element of the slice and returns a new slice with the results
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
// FlatMap applies the given function to each element of the slice and returns a new slice with the flattened results
func FlatMap[T, U any](s []T, f func(T) []U) []U {
return Flatten(Map(s, f))
}

43
api/slicesx/map_test.go Normal file
View file

@ -0,0 +1,43 @@
package slicesx_test
import (
"strconv"
"testing"
"github.com/portainer/portainer/api/slicesx"
)
func Test_Map(t *testing.T) {
test(t, slicesx.Map, "Map integers to strings",
[]int{1, 2, 3, 4, 5},
[]string{"1", "2", "3", "4", "5"},
strconv.Itoa,
)
test(t, slicesx.Map, "Map strings to integers",
[]string{"1", "2", "3", "4", "5"},
[]int{1, 2, 3, 4, 5},
func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
)
}
func Test_FlatMap(t *testing.T) {
test(t, slicesx.FlatMap, "Map integers to strings and flatten",
[]int{1, 2, 3, 4, 5},
[]string{"1", "1", "2", "2", "3", "3", "4", "4", "5", "5"},
func(i int) []string {
x := strconv.Itoa(i)
return []string{x, x}
},
)
test(t, slicesx.FlatMap, "Map strings to integers and flatten",
[]string{"1", "2", "3", "4", "5"},
[]int{1, 1, 2, 2, 3, 3, 4, 4, 5, 5},
func(s string) []int {
n, _ := strconv.Atoi(s)
return []int{n, n}
},
)
}

View file

@ -1,127 +0,0 @@
package slicesx
import (
"strconv"
"testing"
"github.com/stretchr/testify/assert"
)
type filterTestCase[T any] struct {
name string
input []T
expected []T
predicate func(T) bool
}
func TestFilter(t *testing.T) {
intTestCases := []filterTestCase[int]{
{
name: "Filter even numbers",
input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
expected: []int{2, 4, 6, 8},
predicate: func(n int) bool {
return n%2 == 0
},
},
{
name: "Filter odd numbers",
input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
expected: []int{1, 3, 5, 7, 9},
predicate: func(n int) bool {
return n%2 != 0
},
},
}
runTestCases(t, intTestCases)
stringTestCases := []filterTestCase[string]{
{
name: "Filter strings starting with 'A'",
input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
expected: []string{"Apple", "Avocado", "Apricot"},
predicate: func(s string) bool {
return s[0] == 'A'
},
},
{
name: "Filter strings longer than 5 characters",
input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
expected: []string{"Banana", "Avocado", "Grapes", "Apricot"},
predicate: func(s string) bool {
return len(s) > 5
},
},
}
runTestCases(t, stringTestCases)
}
func runTestCases[T any](t *testing.T, testCases []filterTestCase[T]) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
is := assert.New(t)
result := Filter(testCase.input, testCase.predicate)
is.Equal(len(testCase.expected), len(result))
is.ElementsMatch(testCase.expected, result)
})
}
}
func TestMap(t *testing.T) {
intTestCases := []struct {
name string
input []int
expected []string
mapper func(int) string
}{
{
name: "Map integers to strings",
input: []int{1, 2, 3, 4, 5},
expected: []string{"1", "2", "3", "4", "5"},
mapper: strconv.Itoa,
},
}
runMapTestCases(t, intTestCases)
stringTestCases := []struct {
name string
input []string
expected []int
mapper func(string) int
}{
{
name: "Map strings to integers",
input: []string{"1", "2", "3", "4", "5"},
expected: []int{1, 2, 3, 4, 5},
mapper: func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
},
}
runMapTestCases(t, stringTestCases)
}
func runMapTestCases[T, U any](t *testing.T, testCases []struct {
name string
input []T
expected []U
mapper func(T) U
}) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
is := assert.New(t)
result := Map(testCase.input, testCase.mapper)
is.Equal(len(testCase.expected), len(result))
is.ElementsMatch(testCase.expected, result)
})
}
}

View file

@ -0,0 +1,29 @@
package slicesx_test
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
type libFunc[T, U, V any] func([]T, func(T) U) V
type predicateFunc[T, U any] func(T) U
func test[T, U, V any](t *testing.T, libFn libFunc[T, U, V], name string, input []T, expected V, predicate predicateFunc[T, U]) {
t.Helper()
t.Run(name, func(t *testing.T) {
is := assert.New(t)
result := libFn(input, predicate)
switch reflect.TypeOf(result).Kind() {
case reflect.Slice, reflect.Array:
is.Equal(expected, result)
is.ElementsMatch(expected, result)
default:
is.Equal(expected, result)
}
})
}

View file

@ -1,27 +1,5 @@
package slicesx
// Map applies the given function to each element of the slice and returns a new slice with the results
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
// Filter returns a new slice containing only the elements of the slice for which the given predicate returns true
func Filter[T any](s []T, predicate func(T) bool) []T {
n := 0
for _, v := range s {
if predicate(v) {
s[n] = v
n++
}
}
return s[:n]
}
func Unique[T comparable](items []T) []T {
return UniqueBy(items, func(item T) T {
return item

View file

@ -0,0 +1,46 @@
package slicesx_test
import (
"testing"
"github.com/portainer/portainer/api/slicesx"
"github.com/stretchr/testify/assert"
)
func Test_Unique(t *testing.T) {
is := assert.New(t)
t.Run("Should extract unique numbers", func(t *testing.T) {
source := []int{1, 1, 2, 3, 4, 4, 5, 4, 6, 7, 8, 9, 1}
result := slicesx.Unique(source)
expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
is.ElementsMatch(result, expected)
})
t.Run("Should return empty array", func(t *testing.T) {
source := []int{}
result := slicesx.Unique(source)
expected := []int{}
is.ElementsMatch(result, expected)
})
}
func Test_UniqueBy(t *testing.T) {
is := assert.New(t)
t.Run("Should extract unique numbers by property", func(t *testing.T) {
source := []struct{ int }{{1}, {1}, {2}, {3}, {4}, {4}, {5}, {4}, {6}, {7}, {8}, {9}, {1}}
result := slicesx.UniqueBy(source, func(item struct{ int }) int { return item.int })
expected := []struct{ int }{{1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}}
is.ElementsMatch(result, expected)
})
t.Run("Should return empty array", func(t *testing.T) {
source := []int{}
result := slicesx.UniqueBy(source, func(x int) int { return x })
expected := []int{}
is.ElementsMatch(result, expected)
})
}

View file

@ -29,43 +29,79 @@ fieldset[disabled] .btn {
}
.btn.btn-primary {
@apply border-blue-8 bg-blue-8 text-white;
@apply hover:border-blue-9 hover:bg-blue-9 hover:text-white;
@apply th-dark:hover:border-blue-7 th-dark:hover:bg-blue-7;
@apply border-graphite-700 bg-graphite-700 text-mist-100;
@apply hover:border-graphite-700/90 hover:bg-graphite-700/90 hover:text-mist-100;
@apply focus:border-blue-5 focus:shadow-graphite-700/80 focus:text-mist-100;
@apply th-dark:border-mist-100 th-dark:bg-mist-100 th-dark:text-graphite-700;
@apply th-dark:hover:border-mist-100/90 th-dark:hover:bg-mist-100/90 th-dark:hover:text-graphite-700;
@apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-graphite-700;
@apply th-highcontrast:border-mist-100 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700;
@apply th-highcontrast:hover:border-mist-100/90 th-highcontrast:hover:bg-mist-100/90 th-highcontrast:hover:text-graphite-700;
@apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-graphite-700;
}
/* Sidebar background is always dark, so we need to override the primary button styles */
.btn.btn-primary.sidebar {
@apply border-mist-100 bg-mist-100 text-graphite-700;
@apply hover:border-mist-100/90 hover:bg-mist-100/90 hover:text-graphite-700;
@apply focus:border-blue-5 focus:shadow-white/80 focus:text-graphite-700;
}
.btn.btn-primary:active,
.btn.btn-primary.active,
.open > .dropdown-toggle.btn-primary {
@apply border-blue-5 bg-blue-9;
@apply border-graphite-700/80 bg-graphite-700 text-mist-100;
@apply th-dark:border-white/80 th-dark:bg-mist-100 th-dark:text-graphite-700;
@apply th-highcontrast:border-white/80 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700;
}
.nav-pills > li.active > a,
.nav-pills > li.active > a:hover,
.nav-pills > li.active > a:focus {
@apply bg-blue-8;
@apply bg-graphite-700 text-mist-100;
@apply th-dark:bg-mist-100 th-dark:text-graphite-700;
@apply th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700;
}
/* Button Secondary */
.btn.btn-secondary {
@apply border border-solid;
@apply border-blue-8 bg-blue-2 text-blue-9;
@apply hover:bg-blue-3;
@apply border-graphite-700 bg-mist-100 text-graphite-700;
@apply hover:border-graphite-700 hover:bg-graphite-700/10 hover:text-graphite-700;
@apply focus:border-blue-5 focus:shadow-graphite-700/20 focus:text-graphite-700;
@apply th-dark:border-blue-7 th-dark:bg-gray-10 th-dark:text-blue-3;
@apply th-dark:hover:bg-blue-11;
@apply th-dark:border-mist-100 th-dark:bg-graphite-700 th-dark:text-mist-100;
@apply th-dark:hover:border-mist-100 th-dark:hover:bg-mist-100/20 th-dark:hover:text-mist-100;
@apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-mist-100;
@apply th-highcontrast:border-mist-100 th-highcontrast:bg-graphite-700 th-highcontrast:text-mist-100;
@apply th-highcontrast:hover:border-mist-100 th-highcontrast:hover:bg-mist-100/20 th-highcontrast:hover:text-mist-100;
@apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-mist-100;
}
.btn.btn-secondary:active,
.btn.btn-secondary.active,
.open > .dropdown-toggle.btn-secondary {
@apply border-graphite-700 bg-graphite-700/10 text-graphite-700;
@apply th-dark:border-mist-100 th-dark:bg-mist-100/20 th-dark:text-mist-100;
@apply th-highcontrast:border-mist-100 th-highcontrast:bg-mist-100/20 th-highcontrast:text-mist-100;
}
.btn.btn-danger {
@apply border-error-8 bg-error-8;
@apply hover:border-error-7 hover:bg-error-7 hover:text-white;
@apply focus:border-blue-5 focus:shadow-error-8/20 focus:text-white;
@apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-white;
@apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-white;
}
.btn.btn-danger:active,
.btn.btn-danger.active,
.open > .dropdown-toggle.btn-danger {
@apply border-blue-5 bg-error-8 text-white;
@apply border-error-5 bg-error-8 text-white;
}
.btn.btn-dangerlight {
@ -74,6 +110,10 @@ fieldset[disabled] .btn {
@apply hover:bg-error-2 th-dark:hover:bg-error-11;
@apply border-error-5 th-highcontrast:border-error-7 th-dark:border-error-7;
@apply border border-solid;
@apply focus:border-blue-5 focus:shadow-error-8/20 focus:text-error-9;
@apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-white;
@apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80;
}
.btn.btn-icon.btn-dangerlight {
@apply hover:text-error-11 th-dark:hover:text-error-7;
@ -90,15 +130,18 @@ fieldset[disabled] .btn {
/* secondary-grey */
.btn.btn-default,
.btn.btn-light {
@apply border-gray-5 bg-white text-gray-7;
@apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-9;
@apply border-gray-5 bg-white text-gray-8;
@apply hover:border-gray-5 hover:bg-gray-3 hover:text-gray-10;
@apply focus:border-blue-5 focus:shadow-graphite-700/20 focus:text-gray-8;
/* dark mode */
@apply th-dark:border-gray-warm-7 th-dark:bg-gray-iron-10 th-dark:text-gray-warm-4;
@apply th-dark:hover:border-gray-6 th-dark:hover:bg-gray-iron-9 th-dark:hover:text-gray-warm-4;
@apply th-dark:focus:border-blue-5 th-dark:focus:shadow-white/80 th-dark:focus:text-gray-warm-4;
@apply th-highcontrast:border-gray-2 th-highcontrast:bg-black th-highcontrast:text-white;
@apply th-highcontrast:hover:border-gray-6 th-highcontrast:hover:bg-gray-9 th-highcontrast:hover:text-gray-warm-4;
@apply th-highcontrast:focus:border-blue-5 th-highcontrast:focus:shadow-white/80 th-highcontrast:focus:text-white;
}
.btn.btn-light:active,
@ -119,42 +162,17 @@ fieldset[disabled] .btn {
.input-group-btn .btn.active,
.btn-group .btn.active {
@apply border-blue-5 bg-blue-2 text-blue-10;
@apply th-dark:border-blue-9 th-dark:bg-blue-11 th-dark:text-blue-2;
}
/* focus */
.btn.btn-primary:focus,
.btn.btn-secondary:focus,
.btn.btn-light:focus {
@apply border-blue-5;
}
.btn.btn-danger:focus,
.btn.btn-dangerlight:focus {
@apply border-blue-6;
}
.btn.btn-primary:focus,
.btn.btn-secondary:focus,
.btn.btn-light:focus,
.btn.btn-danger:focus,
.btn.btn-dangerlight:focus {
--btn-focus-color: var(--ui-blue-3);
box-shadow: 0px 0px 0px 4px var(--btn-focus-color);
@apply border-graphite-700/80 bg-graphite-700 text-mist-100;
@apply th-dark:border-white/80 th-dark:bg-mist-100 th-dark:text-graphite-700;
@apply th-highcontrast:border-white/80 th-highcontrast:bg-mist-100 th-highcontrast:text-graphite-700;
}
.btn.btn-icon:focus {
box-shadow: none !important;
}
[theme='dark'] .btn.btn-primary:focus,
[theme='dark'] .btn.btn-secondary:focus,
[theme='dark'] .btn.btn-light:focus,
[theme='dark'] .btn.btn-danger:focus,
[theme='dark'] .btn.btn-dangerlight:focus {
--btn-focus-color: var(--ui-blue-11);
.btn:focus {
box-shadow: 0px 0px 0px 2px var(--tw-shadow-color);
}
a.no-link,

View file

@ -1,6 +1,31 @@
{
"black": "#000000",
"white": "#ffffff",
"graphite": {
"10": "#f5f5f6",
"50": "#e5e6e8",
"100": "#ced0d3",
"200": "#abafb5",
"300": "#7b8089",
"400": "#5c6066",
"500": "#484a4e",
"600": "#3a3b3f",
"700": "#2e2f33",
"800": "#222326",
"900": "#161719"
},
"mist": {
"50": "#fcfbfa",
"100": "#f7f6f3",
"200": "#f0f0ec",
"300": "#e8e7e2",
"400": "#e2e1db",
"500": "#d9d8d2",
"600": "#ceccc4",
"700": "#bebcb4",
"800": "#a7a6a0",
"900": "#8b8983"
},
"gray": {
"1": "#fcfcfd",
"2": "#f9fafb",

View file

@ -12,35 +12,40 @@
/*
Extending Calendar.css from react-daterange-picker__calendar
*/
.react-daterange-picker__calendar .react-calendar {
.react-calendar {
background: var(--bg-calendar-color);
color: var(--text-main-color);
@apply th-dark:bg-gray-iron-10;
}
/* calendar nav buttons */
.react-daterange-picker__calendar .react-calendar__navigation button:disabled {
.react-calendar__navigation button:disabled {
background: var(--bg-calendar-color);
@apply opacity-60;
@apply brightness-95 th-dark:brightness-110;
@apply th-dark:bg-gray-iron-7;
}
.react-daterange-picker__calendar .react-calendar__navigation button:enabled:hover,
.react-daterange-picker__calendar .react-calendar__navigation button:enabled:focus {
.react-calendar__navigation button:enabled:hover,
.react-calendar__navigation button:enabled:focus {
background: var(--bg-daterangepicker-color);
@apply th-dark:bg-gray-iron-7;
}
/* date tile */
.react-daterange-picker__calendar .react-calendar__tile:disabled {
background: var(--bg-calendar-color);
.react-calendar__tile:disabled {
@apply opacity-60;
@apply brightness-95 th-dark:brightness-110;
@apply th-dark:bg-gray-iron-7;
}
.react-daterange-picker__calendar .react-calendar__tile:enabled:hover,
.react-daterange-picker__calendar .react-calendar__tile:enabled:focus {
.react-calendar__tile:enabled:hover,
.react-calendar__tile:enabled:focus {
background: var(--bg-daterangepicker-hover);
@apply th-dark:bg-gray-iron-7;
}
/* today's date tile */
.react-daterange-picker__calendar .react-calendar__tile--now {
.react-calendar__tile--now {
@apply th-highcontrast:text-[color:var(--bg-calendar-color)] th-dark:text-[color:var(--bg-calendar-color)];
border-radius: 0.25rem !important;
}
@ -48,23 +53,27 @@
.react-daterange-picker__calendar .react-calendar__tile--now:enabled:focus {
background: var(--bg-daterangepicker-hover);
color: var(--text-daterangepicker-hover);
@apply th-dark:bg-gray-iron-7;
}
/* probably date tile in range */
.react-daterange-picker__calendar .react-calendar__tile--hasActive {
.react-calendar__tile--hasActive {
background: var(--bg-daterangepicker-end-date);
color: var(--text-daterangepicker-end-date);
@apply th-dark:bg-gray-iron-7;
}
.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:hover,
.react-daterange-picker__calendar .react-calendar__tile--hasActive:enabled:focus {
.react-calendar__tile--hasActive:enabled:hover,
.react-calendar__tile--hasActive:enabled:focus {
background: var(--bg-daterangepicker-hover);
color: var(--text-daterangepicker-hover);
@apply th-dark:bg-gray-iron-7;
}
.react-daterange-picker__calendar .react-calendar__tile--active:enabled:hover,
.react-daterange-picker__calendar .react-calendar__tile--active:enabled:focus {
.react-calendar__tile--active:enabled:hover,
.react-calendar__tile--active:enabled:focus {
background: var(--bg-daterangepicker-hover);
color: var(--text-daterangepicker-hover);
@apply th-dark:bg-gray-iron-7;
}
.react-daterange-picker__calendar
@ -75,9 +84,10 @@
}
/* on range select hover */
.react-daterange-picker__calendar .react-calendar--selectRange .react-calendar__tile--hover {
.react-calendar--selectRange .react-calendar__tile--hover {
background: var(--bg-daterangepicker-in-range);
color: var(--text-daterangepicker-in-range);
@apply th-dark:bg-gray-iron-7;
}
/*
@ -111,4 +121,5 @@
.react-calendar__tile--active.react-calendar__month-view__days__day--weekend {
color: var(--text-daterangepicker-active);
@apply th-dark:bg-gray-iron-7;
}

View file

@ -3,6 +3,16 @@
--black-color: var(--ui-black);
--white-color: var(--ui-white);
--graphite-600: #3a3b3f;
--graphite-700: #2e2f33;
--graphite-800: #222326;
--graphite-900: #161719;
--mist-50: #fcfbfa;
--mist-100: #f7f6f3;
--mist-200: #f0f0ec;
--mist-300: #e8e7e2;
--grey-1: #212121;
--grey-2: #181818;
--grey-3: #383838;
@ -58,6 +68,8 @@
--grey-58: #ebf4f8;
--grey-59: #e6e6e6;
--grey-61: rgb(231, 231, 231);
--grey-62: #fdfdfd;
--grey-63: #121212;
--blue-1: #219;
--blue-2: #337ab7;
@ -99,17 +111,16 @@
/* Default Theme */
--bg-card-color: var(--white-color);
--bg-main-color: var(--white-color);
--bg-body-color: var(--grey-9);
--bg-body-color: var(--grey-62);
--bg-checkbox-border-color: var(--grey-49);
--bg-sidebar-color: var(--ui-blue-10);
--bg-sidebar-nav-color: var(--ui-blue-11);
--bg-sidebar-color: var(--graphite-700);
--bg-sidebar-nav-color: var(--graphite-600);
--bg-widget-color: var(--white-color);
--bg-widget-header-color: var(--grey-10);
--bg-widget-table-color: var(--ui-gray-3);
--bg-header-color: var(--white-color);
--bg-hover-table-color: var(--grey-14);
--bg-input-group-addon-color: var(--ui-gray-3);
--bg-btn-default-color: var(--ui-blue-10);
--bg-blocklist-hover-color: var(--ui-blue-2);
--bg-table-color: var(--white-color);
--bg-md-checkbox-color: var(--grey-12);
@ -128,7 +139,8 @@
--border-pagination-color: var(--ui-white);
--bg-pagination-span-color: var(--white-color);
--bg-pagination-hover-color: var(--ui-blue-3);
--bg-motd-body-color: var(--grey-20);
--bg-motd-body-color: var(--mist-50);
--bg-motd-btn-color: var(--graphite-700);
--bg-item-highlighted-color: var(--grey-21);
--bg-item-highlighted-null-color: var(--grey-14);
--bg-panel-body-color: var(--white-color);
@ -144,8 +156,6 @@
--bg-daterangepicker-in-range: var(--grey-58);
--bg-daterangepicker-active: var(--blue-14);
--bg-input-autofill-color: var(--bg-inputbox);
--bg-btn-default-hover-color: var(--ui-blue-9);
--bg-btn-focus: var(--grey-59);
--bg-small-select-color: var(--white-color);
--bg-stepper-item-active: var(--white-color);
--bg-stepper-item-counter: var(--grey-61);
@ -177,7 +187,6 @@
--text-navtabs-color: var(--grey-7);
--text-navtabs-hover-color: var(--grey-6);
--text-nav-tab-active-color: var(--grey-25);
--text-dropdown-menu-color: var(--grey-6);
--text-log-viewer-color: var(--black-color);
--text-json-tree-color: var(--blue-3);
@ -189,6 +198,8 @@
--text-pagination-color: var(--grey-26);
--text-pagination-span-color: var(--grey-3);
--text-pagination-span-hover-color: var(--grey-3);
--text-motd-body-color: var(--black-color);
--text-motd-btn-color: var(--mist-100);
--text-summary-color: var(--black-color);
--text-tooltip-color: var(--white-color);
--text-rzslider-color: var(--grey-36);
@ -203,6 +214,7 @@
--text-button-group-color: var(--ui-gray-9);
--text-button-dangerlight-color: var(--ui-error-5);
--text-stepper-active-color: var(--ui-blue-8);
--border-color: var(--grey-42);
--border-widget-color: var(--grey-43);
--border-sidebar-color: var(--ui-blue-9);
@ -218,7 +230,8 @@
--border-pre-color: var(--grey-43);
--border-pagination-span-color: var(--ui-white);
--border-pagination-hover-color: var(--ui-white);
--border-panel-color: var(--white-color);
--border-motd-body-color: var(--mist-300);
--border-panel-color: var(--mist-300);
--border-input-sm-color: var(--grey-47);
--border-daterangepicker-color: var(--grey-19);
--border-calendar-table: var(--white-color);
@ -265,8 +278,7 @@
--text-log-viewer-color-json-red: var(--text-log-viewer-color);
--text-log-viewer-color-json-blue: var(--text-log-viewer-color);
--bg-body-color: var(--grey-2);
--bg-btn-default-color: var(--grey-3);
--bg-body-color: var(--grey-63);
--bg-blocklist-hover-color: var(--ui-gray-iron-10);
--bg-blocklist-item-selected-color: var(--ui-gray-iron-10);
--bg-card-color: var(--grey-1);
@ -274,8 +286,6 @@
--bg-code-color: var(--grey-2);
--bg-dropdown-menu-color: var(--ui-gray-warm-8);
--bg-main-color: var(--grey-2);
--bg-sidebar-color: var(--grey-1);
--bg-sidebar-nav-color: var(--grey-2);
--bg-widget-color: var(--grey-1);
--bg-widget-header-color: var(--grey-3);
--bg-widget-table-color: var(--grey-3);
@ -296,7 +306,8 @@
--bg-pagination-color: var(--grey-3);
--bg-pagination-span-color: var(--grey-1);
--bg-pagination-hover-color: var(--grey-3);
--bg-motd-body-color: var(--grey-1);
--bg-motd-body-color: var(--graphite-800);
--bg-motd-btn-color: var(--mist-100);
--bg-item-highlighted-color: var(--grey-2);
--bg-item-highlighted-null-color: var(--grey-2);
--bg-panel-body-color: var(--grey-1);
@ -316,8 +327,6 @@
--bg-daterangepicker-in-range: var(--ui-gray-warm-11);
--bg-daterangepicker-active: var(--blue-14);
--bg-input-autofill-color: var(--bg-inputbox);
--bg-btn-default-hover-color: var(--grey-4);
--bg-btn-focus: var(--grey-3);
--bg-small-select-color: var(--grey-2);
--bg-stepper-item-active: var(--grey-1);
--bg-stepper-item-counter: var(--grey-7);
@ -348,7 +357,6 @@
--text-navtabs-color: var(--grey-8);
--text-navtabs-hover-color: var(--grey-9);
--text-nav-tab-active-color: var(--white-color);
--text-dropdown-menu-color: var(--white-color);
--text-log-viewer-color: var(--white-color);
--text-json-tree-color: var(--grey-40);
@ -360,6 +368,8 @@
--text-pagination-color: var(--white-color);
--text-pagination-span-color: var(--ui-white);
--text-pagination-span-hover-color: var(--ui-white);
--text-motd-body-color: var(--mist-100);
--text-motd-btn-color: var(--graphite-700);
--text-summary-color: var(--white-color);
--text-tooltip-color: var(--white-color);
--text-rzslider-color: var(--white-color);
@ -374,6 +384,7 @@
--text-button-group-color: var(--ui-white);
--text-button-dangerlight-color: var(--ui-error-7);
--text-stepper-active-color: var(--ui-white);
--border-color: var(--grey-3);
--border-widget-color: var(--grey-1);
--border-sidebar-color: var(--ui-gray-8);
@ -391,6 +402,7 @@
--border-blocklist-item-selected-color: var(--grey-31);
--border-pagination-span-color: var(--grey-1);
--border-pagination-hover-color: var(--grey-3);
--border-motd-body-color: var(--graphite-800);
--border-panel-color: var(--grey-2);
--border-input-sm-color: var(--grey-3);
--border-daterangepicker-color: var(--grey-3);
@ -450,6 +462,7 @@
--bg-panel-body-color: var(--black-color);
--bg-dropdown-menu-color: var(--ui-gray-warm-8);
--bg-motd-body-color: var(--black-color);
--bg-motd-btn-color: var(--white-color);
--bg-blocklist-hover-color: var(--black-color);
--bg-blocklist-item-selected-color: var(--black-color);
--bg-input-group-addon-color: var(--grey-3);
@ -481,11 +494,8 @@
--bg-navtabs-hover-color: var(--grey-3);
--bg-nav-tab-active-color: var(--ui-black);
--bg-btn-default-color: var(--black-color);
--bg-input-autofill-color: var(--bg-inputbox);
--bg-code-color: var(--ui-black);
--bg-btn-default-hover-color: var(--grey-4);
--bg-btn-focus: var(--black-color);
--bg-small-select-color: var(--black-color);
--bg-stepper-item-active: var(--black-color);
--bg-stepper-item-counter: var(--grey-3);
@ -523,6 +533,8 @@
--text-daterangepicker-end-date: var(--ui-white);
--text-daterangepicker-in-range: var(--white-color);
--text-daterangepicker-active: var(--white-color);
--text-motd-body-color: var(--white-color);
--text-motd-btn-color: var(--black-color);
--text-json-tree-color: var(--white-color);
--text-json-tree-leaf-color: var(--white-color);
--text-json-tree-branch-preview-color: var(--white-color);
@ -553,6 +565,7 @@
--border-input-sm-color: var(--white-color);
--border-pagination-color: var(--grey-1);
--border-pagination-span-color: var(--grey-1);
--border-motd-body-color: var(--white-color);
--border-daterangepicker-color: var(--white-color);
--border-calendar-table: var(--black-color);
--border-daterangepicker: var(--black-color);

View file

@ -201,8 +201,18 @@ pre {
background-color: var(--bg-progress-color);
}
.motd-body {
background-color: var(--bg-motd-body-color) !important;
.widget-body.motd-body {
border: 1px solid var(--border-motd-body-color);
color: var(--text-motd-body-color);
background: var(--bg-motd-body-color) url(../images/purple-gradient.svg) top right / 40% no-repeat;
}
.widget-body.motd-body .btn.btn-link,
.widget-body.motd-body .btn.btn-link:hover {
padding: 0 5px 0 4px;
border-radius: 4px;
background-color: var(--bg-motd-btn-color);
color: var(--text-motd-btn-color);
}
.panel-body {
@ -408,14 +418,10 @@ input:-webkit-autofill {
}
.sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before {
border-right: 8px solid var(--ui-blue-9);
border-right: 8px solid var(--graphite-600);
border-width: 6px 8px 6px 0;
}
[theme='dark'] .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before {
border-right: 8px solid var(--ui-gray-true-9);
}
[theme='highcontrast'] .sidebar.tippy-box[data-placement^='right'] > .tippy-arrow:before {
border-right: 8px solid var(--ui-white);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 772 B

After

Width:  |  Height:  |  Size: 368 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 491 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Before After
Before After

View file

@ -1,35 +1,12 @@
<svg width="38" height="47" viewBox="0 0 38 47" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_dd_1083_50505)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0154 11.0576H14.4116V14.1824H15.0154V11.0576Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6118 11.0576H17.008V14.1824H17.6118V11.0576Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.1593 5.09492L20.5404 4.02313L10.215 9.98588L10.834 11.0577L21.1593 5.09492Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.0989 5.09492L21.7178 4.02313L32.0432 9.98588L31.4243 11.0577L21.0989 5.09492Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.8698 11.0727V9.8349H5.5807V11.0727H33.8698Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M22.6688 28.5534V10.2123H23.9067V29.444C23.5746 29.0666 23.1519 28.7949 22.6688 28.5534Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.5555 28.2364V2.36261H21.7933V28.3873C21.4461 28.2213 20.6008 28.2364 20.5555 28.2364Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.82625 30.8177C7.31669 29.7006 6.32039 27.9193 6.32039 25.8965C6.32039 24.8247 6.6072 23.7681 7.13555 22.8472H17.7024C18.2459 23.7681 18.5176 24.8247 18.5176 25.8965C18.5176 26.8325 18.3968 27.708 18.0194 28.493C17.2194 27.7231 16.0419 27.391 14.8494 27.391C12.736 27.391 10.9245 28.7043 10.4566 30.6667C10.2905 30.6516 10.1848 30.6365 10.0188 30.6365C9.61122 30.6516 9.21873 30.712 8.82625 30.8177Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0191 15.5712H10.8188V18.7865H14.0191V15.5712Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4113 15.5712H7.21101V18.7865H10.4113V15.5712Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.4113 19.1489H7.21101V22.3642H10.4113V19.1489Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.0191 19.1489H10.8188V22.3642H14.0191V19.1489Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6118 19.1489H14.4116V22.3642H17.6118V19.1489Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6118 13.8503H14.4116V17.0657H17.6118V13.8503Z" fill="#13BEF9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.9849 31.3007C11.4227 29.444 13.0983 28.0552 15.0909 28.0552C16.374 28.0552 17.5213 28.6288 18.3062 29.5345C18.9855 29.0666 19.8007 28.7949 20.6913 28.7949C23.016 28.7949 24.903 30.6818 24.903 33.0065C24.903 33.4896 24.8275 33.9424 24.6766 34.3802C25.1898 35.0746 25.5068 35.9501 25.5068 36.8861C25.5068 39.2108 23.6199 41.0977 21.2952 41.0977C20.2687 41.0977 19.3327 40.7354 18.6081 40.1316C17.8383 41.2034 16.5853 41.9129 15.1664 41.9129C13.536 41.9129 12.1171 40.977 11.4076 39.6184C11.1208 39.6787 10.8339 39.7089 10.532 39.7089C8.20731 39.7089 6.30527 37.822 6.30527 35.4973C6.30527 33.1726 8.19222 31.2856 10.532 31.2856C10.683 31.2705 10.8339 31.2705 10.9849 31.3007Z" fill="#13BEF9"/>
<svg width="38" height="47" viewBox="0 0 84 104" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_282_116279)">
<rect width="84" height="104" rx="1" fill="#2E2F33"/>
<path d="M54.2295 74.5278H73.7517V94H54.2295V74.5278Z" fill="#F7F6F3"/>
<path d="M10 11H40.4488C61.4576 11 73.8513 17.5661 73.8513 38.9807V39.7125C73.8513 61.1936 61.4842 67.6931 40.4754 67.6931H33.8295V93.7845H10V11ZM39.0517 49.3986C45.5579 49.3986 48.984 46.9837 48.984 39.7657V38.9341C48.984 31.7493 45.5579 29.2879 39.0517 29.2879H33.8361V49.3919H39.0517V49.3986Z" fill="#F7F6F3"/>
</g>
<defs>
<filter id="filter0_dd_1083_50505" x="0" y="0" width="38" height="47" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0627451 0 0 0 0 0.0941176 0 0 0 0 0.156863 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1083_50505"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1.5"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0627451 0 0 0 0 0.0941176 0 0 0 0 0.156863 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_1083_50505" result="effect2_dropShadow_1083_50505"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_1083_50505" result="shape"/>
</filter>
<clipPath id="clip0_282_116279">
<rect width="84" height="104" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 693 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

View file

@ -1 +1,6 @@
<svg version="1" xmlns="http://www.w3.org/2000/svg" width="420" height="420" viewBox="0 0 315.000000 315.000000"><path d="M163 13.3v6.4l-38.3 22.1-38.2 22.1h-34c-2.8.1-3 .3-3.2 4.5-.2 3.4.1 4.5 1.5 4.8.9.2 16 .3 33.5.2 17.4 0 31.7.2 31.8.5.1.3.2 10.3.4 22.1.1 11.8.3 21.8.4 22.2 0 .5 5.5.8 12 .8h11.9l-.1-22.8-.1-22.7 11.2-.1H163V204l3.5.1c1.9.1 4.1.2 4.7.3 1 .1 1.3-13.7 1.3-65.3V73.7l3.3-.3 3.2-.2.1 66.6v66.7l4.8 3.1 4.7 3.2v-69c-.1-38 .1-69.3.4-69.7.3-.4 17.2-.7 37.5-.7h37l.3-4.6.3-4.6-8.3-.3-8.3-.4-37-21.4c-20.3-11.8-37.2-21.6-37.5-21.8-.3-.3-.5-2.8-.6-5.6-.1-2.9-.2-5.8-.3-6.5-.1-.7-1.7-1.2-4.6-1.2H163v6.3zm0 34.1v16.5h-28.2c-28 0-28.3 0-25.8-1.9 1.4-1 4.3-2.8 6.5-4 2.2-1.1 13.5-7.6 25-14.4 11.6-6.9 21.3-12.5 21.8-12.5.4-.1.7 7.3.7 16.3zm28.6-5.4c7.5 4.4 13.7 8 13.9 8 .1 0 4.5 2.5 9.6 5.7 5.2 3.1 10.5 6.2 11.9 6.9 2.1 1.1-2 1.3-26 1.1l-28.5-.2V47.3l-.1-16.1 2.8 1.4c1.5.8 8.9 5 16.4 9.4zm-55.4 40.5c0 4.9-.1 9.7-.1 10.5-.1 1.3-1.5 1.6-7.3 1.5l-7.3-.1-.5-10.5-.5-10.6 7.8.1 7.9.1v9zm-12.3 23.9c.1 6.6-.2 8.5-1.4 9-.8.3-1.5.2-1.6-.2-.4-3.4-.5-15-.1-16 .2-.6 1-1.2 1.7-1.2.9 0 1.3 2.3 1.4 8.4zm6.7.4c0 4.5-.3 8.2-.8 8.2-.4 0-1.1.1-1.5.2-.5.2-.8-3.7-.9-8.5 0-7.5.2-8.7 1.5-8.5 1.3.3 1.6 1.9 1.7 8.6zm6.9 0c.1 7.2-.1 8.2-1.6 8.2-1.6 0-1.8-1-1.7-8.5.1-7.3.3-8.5 1.7-8.3 1.3.3 1.6 1.8 1.6 8.6z"/><path d="M61.9 108c0 .3-.1 5.7-.2 12l-.1 11.5 12.2.3 12.2.3v-24.6H74c-6.6 0-12 .2-12.1.5zm7.1 11.4c0 8-1 11.1-2.6 8.5-.5-.9-.8-8.6-.5-15.2.1-.9.8-1.7 1.6-1.7 1.2 0 1.5 1.6 1.5 8.4zm7-.1c0 8.6-.3 9.9-2.3 9.1-.8-.3-1.1-3-1-7.7.2-10 .1-9.7 1.8-9.7 1.2 0 1.5 1.6 1.5 8.3zm6.8-.3c.3 8.4-.2 10.7-2.1 9.2-.9-.7-1.2-3.6-1.1-8.9.2-9.2.1-8.6 1.7-8.1.8.3 1.3 3 1.5 7.8zM89.4 107.9c-.2.2-.4 5.8-.4 12.3V132h24.5l-.1-11.3c-.1-6.1-.2-11.7-.3-12.2-.1-1-22.7-1.5-23.7-.6zm7.2 11.2c.1 8.6-.3 10-2.2 9.2-1-.4-1.4-2.5-1.4-8.2 0-4.3.3-8.1.7-8.5 1.8-1.8 2.8.7 2.9 7.5zm6.5-7.4c.4 3.9.3 15.7-.2 16.5-1.7 2.7-2.9-.9-2.9-8.8 0-6.8.3-8.4 1.5-8.4.8 0 1.5.3 1.6.7zm7 .5c.5 5.5.3 13.6-.4 15.1-1.6 3.6-2.7.4-2.7-7.9 0-6.8.3-8.4 1.5-8.4.8 0 1.5.6 1.6 1.2zM94.3 134.8l-5.3.3v12c0 8.8.3 11.9 1.3 12 1.7.1 15.5.1 19.7 0l3.5-.1-.1-10.8c-.1-5.9-.2-11.4-.3-12.2-.1-1.5-6.2-1.9-18.8-1.2zm2.2 12.3c0 6.8-.3 8.4-1.5 8.4s-1.6-1.6-1.8-7.3c-.4-8.1.1-10.6 2.1-9.9.8.2 1.2 2.9 1.2 8.8zm6.6-7.4c.5 5.9.3 14.6-.2 15.5-.4.6-1.2.7-1.8.4-1.2-.8-1.6-15.8-.4-16.9 1.2-1.2 2.3-.7 2.4 1zm7-1c0 .5.1 4.4.2 8.8 0 6.3-.2 8-1.4 8-1.1 0-1.5-1.9-1.7-8.8-.2-7.3 0-8.7 1.3-8.7.8 0 1.5.3 1.6.7zM120 134.8l-3.5.3-.1 11.9c0 7.6.4 12 1.1 12.1 4.6.3 21.5 0 22.3-.5.5-.3 1-5.7 1-12.1l.1-11.5-5.7-.1c-3.1 0-7-.1-8.7-.2-1.6-.1-4.6-.1-6.5.1zm3.4 4.8c1 2.7.7 15.2-.5 15.9-.6.4-1.3.4-1.6.1-.7-.7-1.2-15.7-.6-16.9.7-1.2 2.1-.8 2.7.9zm7.3 6c.2 7.9-.4 10.7-2 10.1-.8-.2-1.2-3.2-1.3-8.3-.1-8.5.1-9.7 1.9-9.1.7.2 1.3 3.1 1.4 7.3zm6.9 0c.2 8.5-.3 10.5-2.2 9.7-1.1-.4-1.4-2.2-1.3-8.2.2-8.3.5-9.4 2.2-8.8.8.2 1.2 3 1.3 7.3zM61.9 136.7c-.4 9-.3 21.6.3 21.9.7.4 13.9.7 21.1.5l2.7-.1v-24H74c-10.7 0-12 .2-12.1 1.7zM69 147c0 5-.4 9-.9 9-1.6 0-2.2-1.7-2.2-7.2-.2-9.6 0-10.8 1.6-10.8 1.2 0 1.5 1.7 1.5 9zm6.8-.5c.3 7.2-.7 10.9-2.4 9.2-.4-.4-.7-3.5-.7-6.9.1-10.2.2-11 1.6-10.6.8.3 1.3 3.1 1.5 8.3zm7 0c.2 4.7-.1 8.2-.8 8.9-1.8 1.8-2.6-.8-2.4-8.6.2-9.3.2-9 1.7-8.6.8.3 1.3 3.1 1.5 8.3zM59.4 166.7c-3.1 7.2-4.3 13.6-4 21.2.5 12.5 4.9 22.4 13.7 31.1 4.1 4.1 5.4 4.8 7.2 4.1 1.2-.4 3.9-.7 5.9-.6 3.6.1 3.8-.1 6.8-6.2 8.2-16.7 29.1-23.3 47.6-15.2 2.4 1.1 4.6 2.4 4.9 2.9 2.5 4.1 5.4-3.3 5.9-15.3.4-8-.6-14-3.5-20.7l-2.1-5H61l-1.6 3.7z"/><path d="M118.5 202.8c-6 .9-11 2.8-15.1 5.7-5.2 3.7-11 11.3-11.9 15.6-.6 2.4-1.2 2.9-3.8 3-9.6.2-17.3 3.5-23.7 10-6 6.2-8.4 12.1-8.6 20.9-.3 10.2 2.1 16.5 9.1 23.5 6.6 6.6 12.5 9 22 9 6.7 0 7.1.1 8.9 3 2.6 4.2 7.3 8.3 13.1 11.3 4.2 2.2 6.3 2.6 13 2.6 9.5 0 16.9-2.9 22.8-8.9l3.7-3.8 6.1 3.3c13 6.9 29.4 3.7 38.8-7.6 5.3-6.5 7.2-11.7 7.3-20.4.2-7-.2-8.6-2.8-13.7-2.6-5-2.9-6.4-2.1-9.3 2.3-8.9-1.7-22.2-8.9-29.4-9.8-9.8-22.8-11.7-38.1-5.5-3 1.2-3.4 1.1-5.8-1.4-5.1-5-17.1-9-24-7.9z"/></svg>
<svg viewBox="0 0 104 104" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(10, 0)">
<path d="M54.2295 74.5278H73.7517V94H54.2295V74.5278Z" fill="#000000"/>
<path d="M10 11H40.4488C61.4576 11 73.8513 17.5661 73.8513 38.9807V39.7125C73.8513 61.1936 61.4842 67.6931 40.4754 67.6931H33.8295V93.7845H10V11ZM39.0517 49.3986C45.5579 49.3986 48.984 46.9837 48.984 39.7657V38.9341C48.984 31.7493 45.5579 29.2879 39.0517 29.2879H33.8361V49.3919H39.0517V49.3986Z" fill="#000000"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 505 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -1,60 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 940 300" style="enable-background:new 0 0 940 300;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#13BEF9;}
.st1{fill:#13BEF9;}
</style>
<g>
<polygon class="st0" points="84.3,76.6 80.3,76.6 80.3,97.3 84.3,97.3 84.3,76.6 "/>
<polygon class="st0" points="101.5,76.6 97.5,76.6 97.5,97.3 101.5,97.3 101.5,76.6 "/>
<polygon class="st0" points="125,37.1 120.9,30 52.5,69.5 56.6,76.6 125,37.1 "/>
<polygon class="st0" points="124.6,37.1 128.7,30 197.1,69.5 193,76.6 124.6,37.1 "/>
<polygon class="st0" points="209.2,76.7 209.2,68.5 21.8,68.5 21.8,76.7 209.2,76.7 "/>
<path class="st0" d="M135,192.5V71h8.2v127.4C141,195.9,138.2,194.1,135,192.5L135,192.5z"/>
<path class="st0" d="M121,190.4V19h8.2v172.4C126.9,190.3,121.3,190.4,121,190.4L121,190.4z"/>
<path class="st0" d="M43.3,207.5c-10-7.4-16.6-19.2-16.6-32.6c0-7.1,1.9-14.1,5.4-20.2h70c3.6,6.1,5.4,13.1,5.4,20.2
c0,6.2-0.8,12-3.3,17.2c-5.3-5.1-13.1-7.3-21-7.3c-14,0-26,8.7-29.1,21.7c-1.1-0.1-1.8-0.2-2.9-0.2
C48.5,206.4,45.9,206.8,43.3,207.5L43.3,207.5z"/>
<path class="st1" d="M219.8,115.5c-10.6,0-19.9,4.9-26.3,12.5v-11.4h-10.6v101.3h10.6v-42.7c6.3,7.8,15.7,12.8,26.3,12.8
c19.8,0,36.1-16.9,36.1-36.4C255.9,131.8,239.6,115.5,219.8,115.5L219.8,115.5z M220.1,177.5c-13.8,0-26-12.2-26-26
c0-14.1,12.2-25.6,26-25.6c14.1,0,24.7,11.5,24.7,25.6C244.8,165.3,234.2,177.5,220.1,177.5L220.1,177.5z"/>
<path class="st1" d="M302.3,187.9c19.8,0,36.1-16.9,36.1-36.4c0-19.8-16.3-36.1-36.1-36.1c-19.8,0-36.1,16.3-36.1,36.1
C266.2,171,282.5,187.9,302.3,187.9L302.3,187.9z M302.3,125.9c14.1,0,25,11.5,25,25.6c0,13.8-10.9,26-25,26c-14.1,0-25-12.2-25-26
C277.3,137.5,288.2,125.9,302.3,125.9L302.3,125.9z"/>
<path class="st1" d="M365.6,116.6H355v69.6h10.6v-38.5c0-14.2,11.2-21.8,23.6-21.8v-10.4c-9.6,0-17.9,4.1-23.6,10.6V116.6
L365.6,116.6z"/>
<polygon class="st1" points="433.8,126.2 433.8,116.6 418.1,116.6 418.1,89.2 407.5,89.2 407.5,116.6 397.1,116.6 397.1,126.2
407.5,126.2 407.5,186.2 418.1,186.2 418.1,126.2 433.8,126.2 "/>
<path class="st1" d="M478.6,187.9c10.6,0,19.9-5.1,26.3-12.8v11.4h10.6v-69.9h-10.6V128c-6.3-7.6-15.7-12.5-26.3-12.5
c-19.8,0-36.1,16.3-36.1,36.1C442.5,171,458.8,187.9,478.6,187.9L478.6,187.9z M478.2,177.5c-14.1,0-24.7-12.2-24.7-26
c0-14.1,10.6-25.6,24.7-25.6c13.8,0,26,11.5,26,25.6C504.2,165.3,492,177.5,478.2,177.5L478.2,177.5z"/>
<path class="st1" d="M543.6,102.5c4,0,7.4-3.3,7.4-7.6c0-3.8-3.5-7.3-7.4-7.3c-4.3,0-7.6,3.5-7.6,7.3
C536,99.2,539.3,102.5,543.6,102.5L543.6,102.5z M538.2,186.2h11.1v-69.6h-11.1V186.2L538.2,186.2z"/>
<path class="st1" d="M571.6,186.2h10.6v-37c0-15.7,8.7-23.6,22.8-23.3c11.6,0,17.9,6.8,17.9,20.6v39.7h10.6v-39.7
c0-22.2-8.5-31-28.5-31c-9.5,0-17.2,3.5-22.8,9.5v-8.4h-10.6V186.2L571.6,186.2z"/>
<path class="st1" d="M720.7,151.5c0-19.8-16.3-36.1-36.1-36.1c-19.8,0-36.1,16.3-36.1,36.1c0,19.5,16.3,36.4,36.1,36.4
c14.1,0,26.6-8.1,32.4-20.1h-13.1c-4.4,5.7-11.2,9.7-19.3,9.7c-12.3,0-22.3-9.5-24.5-21h60.6L720.7,151.5L720.7,151.5z
M684.6,125.9c12.2,0,22.2,8.9,24.5,20.4h-49.1C662.4,134.8,672.3,125.9,684.6,125.9L684.6,125.9z"/>
<path class="st1" d="M747.9,116.6h-10.6v69.6h10.6v-38.5c0-14.2,11.2-21.8,23.6-21.8v-10.4c-9.7,0-17.9,4.1-23.6,10.6V116.6
L747.9,116.6z"/>
<path class="st1" d="M787.5,187c4.7,0,8.7-4,8.7-8.9c0-4.7-4-8.7-8.7-8.7c-4.9,0-8.9,4-8.9,8.7C778.6,183,782.6,187,787.5,187
L787.5,187z"/>
<path class="st1" d="M823.5,102.5c4,0,7.4-3.3,7.4-7.6c0-3.8-3.5-7.3-7.4-7.3c-4.3,0-7.6,3.5-7.6,7.3
C816,99.2,819.3,102.5,823.5,102.5L823.5,102.5z M818.2,186.2h11.1v-69.6h-11.1V186.2L818.2,186.2z"/>
<path class="st1" d="M882.1,187.9c19.8,0,36.1-16.9,36.1-36.4c0-19.8-16.3-36.1-36.1-36.1c-19.8,0-36.1,16.3-36.1,36.1
C846,171,862.3,187.9,882.1,187.9L882.1,187.9z M882.1,125.9c14.1,0,25,11.5,25,25.6c0,13.8-10.9,26-25,26c-14.1,0-25-12.2-25-26
C857.1,137.5,868,125.9,882.1,125.9L882.1,125.9z"/>
<polygon class="st0" points="77.7,106.5 56.5,106.5 56.5,127.8 77.7,127.8 77.7,106.5 "/>
<polygon class="st0" points="53.8,106.5 32.6,106.5 32.6,127.8 53.8,127.8 53.8,106.5 "/>
<polygon class="st0" points="53.8,130.2 32.6,130.2 32.6,151.5 53.8,151.5 53.8,130.2 "/>
<polygon class="st0" points="77.7,130.2 56.5,130.2 56.5,151.5 77.7,151.5 77.7,130.2 "/>
<polygon class="st0" points="101.5,130.2 80.3,130.2 80.3,151.5 101.5,151.5 101.5,130.2 "/>
<polygon class="st0" points="101.5,95.1 80.3,95.1 80.3,116.4 101.5,116.4 101.5,95.1 "/>
<path class="st0" d="M57.6,210.7c2.9-12.3,14-21.5,27.2-21.5c8.5,0,16.1,3.8,21.3,9.8c4.5-3.1,9.9-4.9,15.8-4.9
c15.4,0,27.9,12.5,27.9,27.9c0,3.2-0.5,6.2-1.5,9.1c3.4,4.6,5.5,10.4,5.5,16.6c0,15.4-12.5,27.9-27.9,27.9c-6.8,0-13-2.4-17.8-6.4
c-5.1,7.1-13.4,11.8-22.8,11.8c-10.8,0-20.2-6.2-24.9-15.2c-1.9,0.4-3.8,0.6-5.8,0.6c-15.4,0-28-12.5-28-27.9s12.5-27.9,28-27.9
C55.6,210.5,56.6,210.5,57.6,210.7L57.6,210.7z"/>
</g>
<svg width="1064" height="131" viewBox="0 0 1064 131" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 3.08984H45.77C77.35 3.08984 95.98 12.9598 95.98 45.1498V46.2498C95.98 78.5398 77.39 88.3098 45.81 88.3098H35.82V127.53H0V3.08984ZM43.67 60.8098C53.45 60.8098 58.6 57.1798 58.6 46.3298V45.0798C58.6 34.2798 53.45 30.5798 43.67 30.5798H35.83V60.7998H43.67V60.8098Z" fill="#F7F6F3"/>
<path d="M100.93 71.76V58.86C100.93 15.56 117.85 0 153.84 0C189.83 0 206.73 15.56 206.73 58.86V71.76C206.73 115.05 189.81 130.62 153.84 130.62C117.87 130.62 100.93 115.05 100.93 71.76ZM168.87 71.84V58.77C168.87 36.2 165.81 28.65 153.84 28.65C141.87 28.65 138.78 36.2 138.78 58.77V71.84C138.78 94.41 141.9 101.98 153.84 101.98C165.78 101.98 168.87 94.41 168.87 71.84Z" fill="#F7F6F3"/>
<path d="M213.77 3.08984H256.91C289.3 3.08984 309.24 8.34984 309.24 36.5298V37.0098C309.17 53.8598 302.64 62.4799 291.7 67.1498C301.84 71.2098 305.35 76.0298 306.84 92.9998L308.14 108.5C309.29 121.82 309.77 124.57 311.51 127.53H274.87C273.71 124.4 273.11 120.96 272.8 116.89L271.22 97.1898C270.17 85.3598 268.39 82.3898 258.61 82.3898H249.6V127.52H213.78V3.08984H213.77ZM257.54 55.6898C268.7 55.6898 272.72 52.5198 272.72 43.2398V42.6998C272.72 33.4598 268.7 30.5098 257.54 30.5098H249.59V55.6898H257.54Z" fill="#F7F6F3"/>
<path d="M343.52 32.5898H312.2V3.08984H410.63V32.5998H379.35V127.54H343.53V32.5898H343.52Z" fill="#F7F6F3"/>
<path d="M425.88 3.08984H475.1L508.03 127.54H471.03L465.42 102.18H435.52L429.88 127.54H393.03L425.88 3.08984ZM459.58 73.8998L450.55 32.7398H450.32L441.3 73.8998H459.59H459.58Z" fill="#F7F6F3"/>
<path d="M513.45 3.08984H549.27V127.54H513.45V3.08984Z" fill="#F7F6F3"/>
<path d="M560.29 3.08984H602.69L629.04 85.0499H629.41L627.13 44.7498V3.08984H660.24V127.54H617.85L591.54 50.1298H591.17L593.39 90.5198V127.54H560.28V3.08984H560.29Z" fill="#F7F6F3"/>
<path d="M671.24 3.08984H753.91V32.5998H707.06V50.3998H748.46V79.9099H707.06V98.0398H754.52V127.55H671.24V3.08984Z" fill="#F7F6F3"/>
<path d="M763.42 3.08984H806.56C838.95 3.08984 858.89 8.34984 858.89 36.5298V37.0098C858.82 53.8598 852.29 62.4799 841.35 67.1498C851.49 71.2098 855 76.0298 856.49 92.9998L857.79 108.5C858.94 121.82 859.42 124.57 861.16 127.53H824.52C823.36 124.4 822.76 120.96 822.45 116.89L820.87 97.1898C819.82 85.3598 818.04 82.3898 808.26 82.3898H799.25V127.52H763.43V3.08984H763.42ZM807.2 55.6898C818.36 55.6898 822.38 52.5198 822.38 43.2398V42.6998C822.38 33.4598 818.36 30.5098 807.2 30.5098H799.25V55.6898H807.2Z" fill="#F7F6F3"/>
<path d="M870.36 93.27H904.71V127.53H870.36V93.27Z" fill="#F7F6F3"/>
<path d="M914.92 3.08984H949.85V28.7598H914.92V3.08984ZM914.92 35.5798H949.85V127.54H914.92V35.5798Z" fill="#F7F6F3"/>
<path d="M958.1 71.76V58.86C958.1 15.57 975.02 0 1011.01 0C1047 0 1063.9 15.56 1063.9 58.86V71.76C1063.9 115.05 1046.98 130.62 1011.01 130.62C975.04 130.62 958.1 115.05 958.1 71.76ZM1026.05 71.84V58.77C1026.05 36.2 1022.99 28.65 1011.02 28.65C999.05 28.65 995.96 36.2 995.96 58.77V71.84C995.96 94.41 999.08 101.98 1011.02 101.98C1022.96 101.98 1026.05 94.41 1026.05 71.84Z" fill="#F7F6F3"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

View file

@ -0,0 +1,14 @@
<svg width="1064" height="131" viewBox="0 0 1064 131" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 3.08984H45.77C77.35 3.08984 95.98 12.9598 95.98 45.1498V46.2498C95.98 78.5398 77.39 88.3098 45.81 88.3098H35.82V127.53H0V3.08984ZM43.67 60.8098C53.45 60.8098 58.6 57.1798 58.6 46.3298V45.0798C58.6 34.2798 53.45 30.5798 43.67 30.5798H35.83V60.7998H43.67V60.8098Z" fill="#2E2F33"/>
<path d="M100.93 71.76V58.86C100.93 15.56 117.85 0 153.84 0C189.83 0 206.73 15.56 206.73 58.86V71.76C206.73 115.05 189.81 130.62 153.84 130.62C117.87 130.62 100.93 115.05 100.93 71.76ZM168.87 71.84V58.77C168.87 36.2 165.81 28.65 153.84 28.65C141.87 28.65 138.78 36.2 138.78 58.77V71.84C138.78 94.41 141.9 101.98 153.84 101.98C165.78 101.98 168.87 94.41 168.87 71.84Z" fill="#2E2F33"/>
<path d="M213.77 3.08984H256.91C289.3 3.08984 309.24 8.34984 309.24 36.5298V37.0098C309.17 53.8598 302.64 62.4799 291.7 67.1498C301.84 71.2098 305.35 76.0298 306.84 92.9998L308.14 108.5C309.29 121.82 309.77 124.57 311.51 127.53H274.87C273.71 124.4 273.11 120.96 272.8 116.89L271.22 97.1898C270.17 85.3598 268.39 82.3898 258.61 82.3898H249.6V127.52H213.78V3.08984H213.77ZM257.54 55.6898C268.7 55.6898 272.72 52.5198 272.72 43.2398V42.6998C272.72 33.4598 268.7 30.5098 257.54 30.5098H249.59V55.6898H257.54Z" fill="#2E2F33"/>
<path d="M343.52 32.5898H312.2V3.08984H410.63V32.5998H379.35V127.54H343.53V32.5898H343.52Z" fill="#2E2F33"/>
<path d="M425.88 3.08984H475.1L508.03 127.54H471.03L465.42 102.18H435.52L429.88 127.54H393.03L425.88 3.08984ZM459.58 73.8998L450.55 32.7398H450.32L441.3 73.8998H459.59H459.58Z" fill="#2E2F33"/>
<path d="M513.45 3.08984H549.27V127.54H513.45V3.08984Z" fill="#2E2F33"/>
<path d="M560.29 3.08984H602.69L629.04 85.0499H629.41L627.13 44.7498V3.08984H660.24V127.54H617.85L591.54 50.1298H591.17L593.39 90.5198V127.54H560.28V3.08984H560.29Z" fill="#2E2F33"/>
<path d="M671.24 3.08984H753.91V32.5998H707.06V50.3998H748.46V79.9099H707.06V98.0398H754.52V127.55H671.24V3.08984Z" fill="#2E2F33"/>
<path d="M763.42 3.08984H806.56C838.95 3.08984 858.89 8.34984 858.89 36.5298V37.0098C858.82 53.8598 852.29 62.4799 841.35 67.1498C851.49 71.2098 855 76.0298 856.49 92.9998L857.79 108.5C858.94 121.82 859.42 124.57 861.16 127.53H824.52C823.36 124.4 822.76 120.96 822.45 116.89L820.87 97.1898C819.82 85.3598 818.04 82.3898 808.26 82.3898H799.25V127.52H763.43V3.08984H763.42ZM807.2 55.6898C818.36 55.6898 822.38 52.5198 822.38 43.2398V42.6998C822.38 33.4598 818.36 30.5098 807.2 30.5098H799.25V55.6898H807.2Z" fill="#2E2F33"/>
<path d="M870.36 93.27H904.71V127.53H870.36V93.27Z" fill="#2E2F33"/>
<path d="M914.92 3.08984H949.85V28.7598H914.92V3.08984ZM914.92 35.5798H949.85V127.54H914.92V35.5798Z" fill="#2E2F33"/>
<path d="M958.1 71.76V58.86C958.1 15.57 975.02 0 1011.01 0C1047 0 1063.9 15.56 1063.9 58.86V71.76C1063.9 115.05 1046.98 130.62 1011.01 130.62C975.04 130.62 958.1 115.05 958.1 71.76ZM1026.05 71.84V58.77C1026.05 36.2 1022.99 28.65 1011.02 28.65C999.05 28.65 995.96 36.2 995.96 58.77V71.84C995.96 94.41 999.08 101.98 1011.02 101.98C1022.96 101.98 1026.05 94.41 1026.05 71.84Z" fill="#2E2F33"/>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -0,0 +1,522 @@
<svg width="1421" height="507" viewBox="0 0 1421 507" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_414_125114)">
<path d="M1119.89 1.0446V9.76338H1128.63C1128.71 6.87373 1128.53 3.93425 1128.63 1.0446C1125.74 1.14425 1122.8 0.969872 1119.91 1.0446H1119.89Z" fill="#C080FF"/>
<path d="M1137.33 9.76292H1146.05V1.01921C1143.16 0.944482 1140.22 1.11885 1137.33 1.01921C1137.43 3.90886 1137.26 6.84833 1137.33 9.73798V9.76292Z" fill="#C080FF"/>
<path d="M1173.45 9.76292H1182.17V1.01921C1179.28 0.944482 1176.34 1.11885 1173.45 1.01921C1173.55 3.90886 1173.38 6.84833 1173.45 9.73798V9.76292Z" fill="#C080FF"/>
<path d="M1190.89 1.0446V9.76338H1199.64C1199.71 6.87373 1199.54 3.93425 1199.64 1.0446C1196.75 1.14425 1193.81 0.969872 1190.92 1.0446H1190.89Z" fill="#C080FF"/>
<path d="M1208.33 9.76292H1217.05V1.01921C1214.16 0.944482 1211.22 1.11885 1208.33 1.01921C1208.43 3.90886 1208.26 6.84833 1208.33 9.73798V9.76292Z" fill="#C080FF"/>
<path d="M1154.77 1.04403V9.76281H1164.76C1164.83 6.87316 1164.66 3.93368 1164.76 1.04403C1161.44 1.11876 1158.11 0.969304 1154.79 1.04403H1154.77Z" fill="#C080FF"/>
<path d="M1244.46 9.76292H1253.18V1.01921C1250.29 0.944482 1247.35 1.11885 1244.46 1.01921C1244.56 3.90886 1244.38 6.84833 1244.46 9.73798V9.76292Z" fill="#C080FF"/>
<path d="M1225.77 1.04403V9.76281H1235.76C1235.84 6.87316 1235.66 3.93368 1235.76 1.04403C1232.45 1.11876 1229.11 0.969304 1225.8 1.04403H1225.77Z" fill="#C080FF"/>
<path d="M1288.06 9.76172C1288.16 12.6264 1288.16 15.6157 1288.06 18.4805C1291.35 18.3808 1294.73 18.3808 1298.02 18.4805C1297.92 15.6157 1297.92 12.6264 1298.02 9.76172C1294.73 9.86136 1291.35 9.86136 1288.06 9.76172Z" fill="#C080FF"/>
<path d="M1298.02 37.1637V45.8825H1306.77C1306.84 42.9929 1306.67 40.0534 1306.77 37.1637C1303.88 37.2634 1300.94 37.089 1298.05 37.1637H1298.02Z" fill="#C080FF"/>
<path d="M1306.75 37.1645C1309.64 37.0649 1312.58 37.0649 1315.47 37.1645C1315.37 33.8763 1315.37 30.4884 1315.47 27.2002C1312.6 27.2999 1309.61 27.2999 1306.75 27.2002C1306.85 30.4884 1306.85 33.8763 1306.75 37.1645Z" fill="#C080FF"/>
<path d="M1261.9 37.1637V45.8825H1270.64C1270.72 42.9929 1270.54 40.0534 1270.64 37.1637C1267.75 37.2634 1264.81 37.089 1261.92 37.1637H1261.9Z" fill="#C080FF"/>
<path d="M1279.34 18.4548C1279.26 21.3444 1279.44 24.2839 1279.34 27.1735C1282.23 27.0739 1285.17 27.2483 1288.06 27.1735C1288.13 24.2839 1287.96 21.3444 1288.06 18.4548C1285.17 18.5544 1282.23 18.38 1279.34 18.4548Z" fill="#C080FF"/>
<path d="M1270.62 27.2002C1270.72 30.4884 1270.72 33.8763 1270.62 37.1645C1273.51 37.0649 1276.45 37.0649 1279.34 37.1645C1279.24 33.8763 1279.24 30.4884 1279.34 27.2002C1276.47 27.2999 1273.48 27.2999 1270.62 27.2002Z" fill="#C080FF"/>
<path d="M1270.62 18.4551H1261.9V27.1988C1264.79 27.2736 1267.73 27.0992 1270.62 27.1988C1270.52 24.3092 1270.69 21.3697 1270.62 18.4801V18.4551Z" fill="#C080FF"/>
<path d="M1298.02 27.1989C1300.91 27.2737 1303.85 27.0993 1306.74 27.1989C1306.64 24.3093 1306.82 21.3698 1306.74 18.4802C1303.85 18.4054 1300.91 18.5798 1298.02 18.4802C1298.12 21.3698 1297.95 24.3093 1298.02 27.1989Z" fill="#C080FF"/>
<path d="M1279.34 45.882H1288.06V37.1383C1285.17 37.0636 1282.23 37.238 1279.34 37.1383C1279.44 40.028 1279.26 42.9674 1279.34 45.8571V45.882Z" fill="#C080FF"/>
<path d="M1324.18 27.2017V18.458H1315.44C1315.36 21.3477 1315.54 24.2872 1315.44 27.1768C1318.33 27.0772 1321.27 27.2515 1324.16 27.1768L1324.18 27.2017Z" fill="#C080FF"/>
<path d="M1315.46 45.882H1324.18V37.1383C1321.29 37.0636 1318.35 37.238 1315.46 37.1383C1315.56 40.028 1315.39 42.9674 1315.46 45.8571V45.882Z" fill="#C080FF"/>
<path d="M1261.9 1.0446V9.76338H1270.64C1270.72 6.87373 1270.54 3.93425 1270.64 1.0446C1267.75 1.14425 1264.81 0.969872 1261.92 1.0446H1261.9Z" fill="#C080FF"/>
<path d="M1288.06 1.02116C1285.17 0.946434 1282.23 1.12081 1279.34 1.02116C1279.44 3.91081 1279.26 6.85029 1279.34 9.73994C1282.23 9.81467 1285.17 9.6403 1288.06 9.73994C1287.96 6.85029 1288.13 3.91081 1288.06 1.02116Z" fill="#C080FF"/>
<path d="M1298.02 1.02116C1297.95 3.91081 1298.12 6.85029 1298.02 9.73994C1300.91 9.6403 1303.85 9.81467 1306.74 9.73994C1306.82 6.85029 1306.64 3.91081 1306.74 1.02116C1303.85 1.12081 1300.91 0.946434 1298.02 1.02116Z" fill="#C080FF"/>
<path d="M1315.46 9.76292H1324.18V1.01921C1321.29 0.944482 1318.35 1.11885 1315.46 1.01921C1315.56 3.90886 1315.39 6.84833 1315.46 9.73798V9.76292Z" fill="#C080FF"/>
<path d="M1341.62 18.4551H1332.9V27.1988C1335.79 27.2736 1338.73 27.0992 1341.62 27.1988C1341.52 24.3092 1341.7 21.3697 1341.62 18.4801V18.4551Z" fill="#C080FF"/>
<path d="M1350.34 45.8821H1360.31V37.1384C1356.99 37.0886 1353.66 37.2381 1350.34 37.1384C1350.44 40.0281 1350.27 42.9675 1350.34 45.8572V45.8821Z" fill="#C080FF"/>
<path d="M1341.62 27.2002C1341.72 30.4884 1341.72 33.8763 1341.62 37.1645C1344.51 37.0649 1347.45 37.0898 1350.34 37.1645C1350.24 33.8763 1350.24 30.4884 1350.34 27.2002C1347.45 27.275 1344.51 27.2999 1341.62 27.2002Z" fill="#C080FF"/>
<path d="M1360.31 9.76172C1360.41 12.6264 1360.41 15.6157 1360.31 18.4805C1363.2 18.4057 1366.14 18.3808 1369.03 18.4805C1368.93 15.6157 1368.93 12.6264 1369.03 9.76172C1366.14 9.86136 1363.2 9.83645 1360.31 9.76172Z" fill="#C080FF"/>
<path d="M1332.9 37.1637V45.8825H1341.65C1341.72 42.9929 1341.55 40.0534 1341.65 37.1637C1338.76 37.2634 1335.82 37.089 1332.93 37.1637H1332.9Z" fill="#C080FF"/>
<path d="M1350.34 18.4548C1350.27 21.3444 1350.44 24.2839 1350.34 27.1735C1353.66 27.0739 1356.99 27.2483 1360.31 27.1735C1360.38 24.2839 1360.21 21.3444 1360.31 18.4548C1356.99 18.5544 1353.66 18.38 1350.34 18.4548Z" fill="#C080FF"/>
<path d="M1332.9 1.0446V9.76338H1341.65C1341.72 6.87373 1341.55 3.93425 1341.65 1.0446C1338.76 1.14425 1335.82 0.969872 1332.93 1.0446H1332.9Z" fill="#C080FF"/>
<path d="M1360.31 1.01634C1356.99 0.96652 1353.66 1.11598 1350.34 1.01634C1350.44 3.90599 1350.27 6.84547 1350.34 9.73512C1353.66 9.78494 1356.99 9.63547 1360.31 9.73512C1360.21 6.84547 1360.38 3.90599 1360.31 1.01634Z" fill="#C080FF"/>
<path d="M1386.47 54.5788C1386.4 57.4685 1386.57 60.4079 1386.47 63.2976C1389.36 63.1979 1392.3 63.3723 1395.19 63.2976C1395.27 60.4079 1395.09 57.4685 1395.19 54.5788C1392.3 54.6784 1389.36 54.5041 1386.47 54.5788Z" fill="#C080FF"/>
<path d="M1386.47 80.7591H1395.19V72.0153C1392.3 71.9406 1389.36 72.115 1386.47 72.0153C1386.57 74.905 1386.4 77.8445 1386.47 80.7341V80.7591Z" fill="#C080FF"/>
<path d="M1369.03 72.0407V80.7595H1377.78C1377.85 77.8698 1377.68 74.9303 1377.78 72.0407C1374.89 72.1403 1371.95 71.966 1369.06 72.0407H1369.03Z" fill="#C080FF"/>
<path d="M1377.75 54.5791H1369.03V63.3228C1371.92 63.3975 1374.86 63.2231 1377.75 63.3228C1377.65 60.4331 1377.83 57.4936 1377.75 54.604V54.5791Z" fill="#C080FF"/>
<path d="M1403.91 72.0407V80.7595H1412.66C1412.73 77.8698 1412.56 74.9303 1412.66 72.0407C1409.77 72.1403 1406.83 71.966 1403.94 72.0407H1403.91Z" fill="#C080FF"/>
<path d="M1403.91 63.323C1406.8 63.3977 1409.74 63.2233 1412.63 63.323C1412.53 60.4333 1412.7 57.4938 1412.63 54.6042C1409.74 54.5295 1406.8 54.7038 1403.91 54.6042C1404.01 57.4938 1403.83 60.4333 1403.91 63.323Z" fill="#C080FF"/>
<path d="M1412.63 72.0381C1415.52 71.9384 1418.46 71.9633 1421.35 72.0381C1421.25 69.1484 1421.25 66.209 1421.35 63.3193C1418.46 63.394 1415.52 63.4189 1412.63 63.3193C1412.73 66.184 1412.73 69.1733 1412.63 72.0381Z" fill="#C080FF"/>
<path d="M1377.75 63.3193C1377.85 66.209 1377.85 69.1484 1377.75 72.0381C1380.64 71.9384 1383.58 71.9384 1386.47 72.0381C1386.37 69.1733 1386.37 66.184 1386.47 63.3193C1383.61 63.4189 1380.62 63.4189 1377.75 63.3193Z" fill="#C080FF"/>
<path d="M1395.19 54.6036C1398.05 54.504 1401.04 54.504 1403.91 54.6036C1403.81 51.7389 1403.81 48.7496 1403.91 45.8848C1401.04 45.9845 1398.05 45.9845 1395.19 45.8848C1395.29 48.7496 1395.29 51.7389 1395.19 54.6036Z" fill="#C080FF"/>
<path d="M1369.03 37.1637V45.8825H1377.78C1377.85 42.9929 1377.68 40.0534 1377.78 37.1637C1374.89 37.2634 1371.95 37.089 1369.06 37.1637H1369.03Z" fill="#C080FF"/>
<path d="M1386.47 27.2002C1383.61 27.2999 1380.62 27.2999 1377.75 27.2002C1377.85 30.4884 1377.85 33.8763 1377.75 37.1645C1380.62 37.0649 1383.61 37.0649 1386.47 37.1645C1386.37 33.8763 1386.37 30.4884 1386.47 27.2002Z" fill="#C080FF"/>
<path d="M1395.19 27.2017V18.458H1386.44C1386.37 21.3477 1386.54 24.2872 1386.44 27.1768C1389.33 27.0772 1392.27 27.2515 1395.16 27.1768L1395.19 27.2017Z" fill="#C080FF"/>
<path d="M1377.75 18.4548C1374.86 18.38 1371.92 18.5544 1369.03 18.4548C1369.13 21.3444 1368.95 24.2839 1369.03 27.1735C1371.92 27.2483 1374.86 27.0739 1377.75 27.1735C1377.65 24.2839 1377.82 21.3444 1377.75 18.4548Z" fill="#C080FF"/>
<path d="M1369.03 1.02116C1368.95 3.91081 1369.13 6.85029 1369.03 9.73994C1371.92 9.6403 1374.86 9.81467 1377.75 9.73994C1377.82 6.85029 1377.65 3.91081 1377.75 1.02116C1374.86 1.12081 1371.92 0.946434 1369.03 1.02116Z" fill="#C080FF"/>
<path d="M1386.47 9.76292H1395.19V1.01921C1392.3 0.944482 1389.36 1.11885 1386.47 1.01921C1386.57 3.90886 1386.4 6.84833 1386.47 9.73798V9.76292Z" fill="#C080FF"/>
<path d="M1395.19 37.1393C1392.3 37.0646 1389.36 37.239 1386.47 37.1393C1386.57 40.029 1386.4 42.9685 1386.47 45.8581C1389.36 45.9328 1392.3 45.7585 1395.19 45.8581C1395.09 42.9685 1395.27 40.029 1395.19 37.1393Z" fill="#C080FF"/>
<path d="M1403.91 37.1393C1403.83 40.029 1404.01 42.9685 1403.91 45.8581C1406.8 45.7585 1409.74 45.9328 1412.63 45.8581C1412.7 42.9685 1412.53 40.029 1412.63 37.1393C1409.74 37.239 1406.8 37.0646 1403.91 37.1393Z" fill="#C080FF"/>
<path d="M1412.63 18.4551H1403.89V27.1988C1406.78 27.2736 1409.71 27.0992 1412.61 27.1988C1412.51 24.3092 1412.68 21.3697 1412.61 18.4801L1412.63 18.4551Z" fill="#C080FF"/>
<path d="M1412.63 27.2002C1412.73 30.4884 1412.73 33.8763 1412.63 37.1645C1415.52 37.0649 1418.46 37.0898 1421.35 37.1645C1421.25 33.8763 1421.25 30.4884 1421.35 27.2002C1418.46 27.275 1415.52 27.2999 1412.63 27.2002Z" fill="#C080FF"/>
<path d="M1403.91 1.0446V9.76338H1412.66C1412.73 6.87373 1412.56 3.93425 1412.66 1.0446C1409.77 1.14425 1406.83 0.969872 1403.94 1.0446H1403.91Z" fill="#C080FF"/>
<path d="M1350.34 160.472V170.436H1360.31V160.472H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 427.02V436.984H1360.31V427.02H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 498.016V507.98H1360.31V498.016H1350.34Z" fill="#C080FF"/>
<path d="M1154.77 231.468V241.432H1164.73V231.468H1154.77Z" fill="#C080FF"/>
<path d="M1154.77 160.472V170.436H1164.73V160.472H1154.77Z" fill="#C080FF"/>
<path d="M1225.77 160.472V170.436H1235.74V160.472H1225.77Z" fill="#C080FF"/>
<path d="M1225.77 231.468V241.432H1235.74V231.468H1225.77Z" fill="#C080FF"/>
<path d="M1403.91 108.16V116.879H1412.66C1412.73 113.989 1412.56 111.049 1412.66 108.16C1409.77 108.259 1406.83 108.085 1403.94 108.16H1403.91Z" fill="#C080FF"/>
<path d="M1421.35 98.1973C1418.46 98.272 1415.52 98.2969 1412.63 98.1973C1412.73 101.485 1412.73 104.873 1412.63 108.161C1415.52 108.062 1418.46 108.086 1421.35 108.161C1421.25 104.873 1421.25 101.485 1421.35 98.1973Z" fill="#C080FF"/>
<path d="M1412.63 89.4521H1403.89V98.1958C1406.78 98.2705 1409.71 98.0962 1412.61 98.1958C1412.51 95.3062 1412.68 92.3667 1412.61 89.477L1412.63 89.4521Z" fill="#C080FF"/>
<path d="M1403.91 160.472V170.436H1412.66V160.472H1403.91Z" fill="#C080FF"/>
<path d="M1403.91 231.468V241.432H1412.66V231.468H1403.91Z" fill="#C080FF"/>
<path d="M1386.47 160.472V170.436H1395.21V160.472H1386.47Z" fill="#C080FF"/>
<path d="M1386.47 231.468V241.432H1395.21V231.468H1386.47Z" fill="#C080FF"/>
<path d="M1386.47 116.88H1395.19V108.136C1392.3 108.061 1389.36 108.235 1386.47 108.136C1386.57 111.025 1386.4 113.965 1386.47 116.855V116.88Z" fill="#C080FF"/>
<path d="M1369.03 108.16V116.879H1377.78C1377.85 113.989 1377.68 111.049 1377.78 108.16C1374.89 108.259 1371.95 108.085 1369.06 108.16H1369.03Z" fill="#C080FF"/>
<path d="M1386.47 98.1973C1383.58 98.2969 1380.64 98.2969 1377.75 98.1973C1377.85 101.485 1377.85 104.873 1377.75 108.161C1380.64 108.062 1383.58 108.062 1386.47 108.161C1386.37 104.873 1386.37 101.485 1386.47 98.1973Z" fill="#C080FF"/>
<path d="M1395.19 98.1939V89.4502H1386.44C1386.37 92.3399 1386.54 95.2793 1386.44 98.169C1389.33 98.0694 1392.27 98.2437 1395.16 98.169L1395.19 98.1939Z" fill="#C080FF"/>
<path d="M1377.75 89.4521H1369.03V98.1958C1371.92 98.2705 1374.86 98.0962 1377.75 98.1958C1377.65 95.3062 1377.83 92.3667 1377.75 89.477V89.4521Z" fill="#C080FF"/>
<path d="M1369.03 160.472V170.436H1377.78V160.472H1369.03Z" fill="#C080FF"/>
<path d="M1369.03 231.468V241.432H1377.78V231.468H1369.03Z" fill="#C080FF"/>
<path d="M1332.9 72.0407V80.7595H1341.65C1341.72 77.8698 1341.55 74.9303 1341.65 72.0407C1338.76 72.1403 1335.82 71.966 1332.93 72.0407H1332.9Z" fill="#C080FF"/>
<path d="M1341.62 54.5791H1332.9V63.3228C1335.79 63.3975 1338.73 63.2231 1341.62 63.3228C1341.52 60.4331 1341.7 57.4936 1341.62 54.604V54.5791Z" fill="#C080FF"/>
<path d="M1350.34 63.3193C1347.45 63.394 1344.51 63.4189 1341.62 63.3193C1341.72 66.209 1341.72 69.1484 1341.62 72.0381C1344.51 71.9384 1347.45 71.9633 1350.34 72.0381C1350.24 69.1484 1350.24 66.209 1350.34 63.3193Z" fill="#C080FF"/>
<path d="M1350.34 80.7592H1360.31V72.0154C1356.99 71.9656 1353.66 72.1151 1350.34 72.0154C1350.44 74.9051 1350.27 77.8445 1350.34 80.7341V80.7592Z" fill="#C080FF"/>
<path d="M1360.31 63.3208V54.5771H1350.32C1350.24 57.4667 1350.42 60.4062 1350.32 63.2958C1353.63 63.2211 1356.97 63.3706 1360.28 63.2958L1360.31 63.3208Z" fill="#C080FF"/>
<path d="M1350.34 89.4502V98.1939H1360.31V89.4502H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 108.135V116.879H1360.31V108.135H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 125.573V134.317H1360.31V125.573H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 143.008V151.752H1360.31V143.008H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 179.131V187.875H1360.31V179.131H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 196.57V205.314H1360.31V196.57H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 214.004V222.748H1360.31V214.004H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 250.128V258.871H1360.31V250.128H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 285.001V293.744H1360.31V285.001H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 321.124V329.868H1360.31V321.124H1350.34Z" fill="#C080FF"/>
<path d="M1350.34 355.997V364.741H1360.31V355.997H1350.34Z" fill="#C080FF"/>
<path d="M1332.9 160.472V170.436H1341.65V160.472H1332.9Z" fill="#C080FF"/>
<path d="M1332.9 231.468V241.432H1341.65V231.468H1332.9Z" fill="#C080FF"/>
<path d="M1315.46 160.472V170.436H1324.21V160.472H1315.46Z" fill="#C080FF"/>
<path d="M1298.02 160.472V170.436H1306.77V160.472H1298.02Z" fill="#C080FF"/>
<path d="M1298.02 231.468V241.432H1306.77V231.468H1298.02Z" fill="#C080FF"/>
<path d="M1279.34 160.472V170.436H1288.08V160.472H1279.34Z" fill="#C080FF"/>
<path d="M1279.34 427.02V436.984H1288.08V427.02H1279.34Z" fill="#C080FF"/>
<path d="M1261.9 160.472V170.436H1270.64V160.472H1261.9Z" fill="#C080FF"/>
<path d="M1261.9 231.468V241.432H1270.64V231.468H1261.9Z" fill="#C080FF"/>
<path d="M1244.46 160.472V170.436H1253.2V160.472H1244.46Z" fill="#C080FF"/>
<path d="M1235.74 18.4561H1225.77V27.1998C1229.09 27.2496 1232.42 27.1001 1235.74 27.1998C1235.64 24.3101 1235.81 21.3706 1235.74 18.481V18.4561Z" fill="#C080FF"/>
<path d="M1253.18 27.2017V18.458H1244.43C1244.36 21.3477 1244.53 24.2872 1244.43 27.1768C1247.32 27.0772 1250.26 27.2515 1253.15 27.1768L1253.18 27.2017Z" fill="#C080FF"/>
<path d="M1244.46 37.1645C1244.36 33.8763 1244.36 30.4884 1244.46 27.2002C1241.57 27.2999 1238.63 27.275 1235.74 27.2002C1235.84 30.4884 1235.84 33.8763 1235.74 37.1645C1238.63 37.0898 1241.57 37.0649 1244.46 37.1645Z" fill="#C080FF"/>
<path d="M1244.46 45.882H1253.18V37.1383C1250.29 37.0636 1247.35 37.238 1244.46 37.1383C1244.56 40.028 1244.38 42.9674 1244.46 45.8571V45.882Z" fill="#C080FF"/>
<path d="M1235.74 37.1632C1232.42 37.238 1229.09 37.0885 1225.77 37.1632V45.882H1235.76C1235.84 42.9924 1235.66 40.0529 1235.76 37.1632H1235.74Z" fill="#C080FF"/>
<path d="M1225.77 54.5771V63.3208H1235.74V54.5771H1225.77Z" fill="#C080FF"/>
<path d="M1225.77 72.0156V80.7594H1235.74V72.0156H1225.77Z" fill="#C080FF"/>
<path d="M1225.77 89.4502V98.1939H1235.74V89.4502H1225.77Z" fill="#C080FF"/>
<path d="M1225.77 125.573V134.317H1235.74V125.573H1225.77Z" fill="#C080FF"/>
<path d="M1225.77 196.57V205.314H1235.74V196.57H1225.77Z" fill="#C080FF"/>
<path d="M1208.33 160.472V170.436H1217.08V160.472H1208.33Z" fill="#C080FF"/>
<path d="M1190.89 37.1637V45.8825H1199.64C1199.71 42.9929 1199.54 40.0534 1199.64 37.1637C1196.75 37.2634 1193.81 37.089 1190.92 37.1637H1190.89Z" fill="#C080FF"/>
<path d="M1208.33 45.882H1217.05V37.1383C1214.16 37.0636 1211.22 37.238 1208.33 37.1383C1208.43 40.028 1208.26 42.9674 1208.33 45.8571V45.882Z" fill="#C080FF"/>
<path d="M1217.05 27.2017V18.458H1208.31C1208.23 21.3477 1208.41 24.2872 1208.31 27.1768C1211.2 27.0772 1214.14 27.2515 1217.03 27.1768L1217.05 27.2017Z" fill="#C080FF"/>
<path d="M1199.61 37.1645C1202.5 37.0649 1205.44 37.0649 1208.33 37.1645C1208.23 33.8763 1208.23 30.4884 1208.33 27.2002C1205.44 27.2999 1202.5 27.2999 1199.61 27.2002C1199.71 30.4884 1199.71 33.8763 1199.61 37.1645Z" fill="#C080FF"/>
<path d="M1199.61 18.4551H1190.89V27.1988C1193.78 27.2736 1196.72 27.0992 1199.61 27.1988C1199.51 24.3092 1199.69 21.3697 1199.61 18.4801V18.4551Z" fill="#C080FF"/>
<path d="M1190.89 160.472V170.436H1199.64V160.472H1190.89Z" fill="#C080FF"/>
<path d="M1173.45 160.472V170.436H1182.2V160.472H1173.45Z" fill="#C080FF"/>
<path d="M1164.73 37.1632C1161.42 37.238 1158.08 37.0885 1154.77 37.1632V45.882H1164.76C1164.83 42.9924 1164.66 40.0529 1164.76 37.1632H1164.73Z" fill="#C080FF"/>
<path d="M1173.45 37.1645C1173.35 33.8763 1173.35 30.4884 1173.45 27.2002C1170.56 27.2999 1167.62 27.275 1164.73 27.2002C1164.83 30.4884 1164.83 33.8763 1164.73 37.1645C1167.62 37.0898 1170.56 37.0649 1173.45 37.1645Z" fill="#C080FF"/>
<path d="M1164.73 18.4561H1154.77V27.1998C1158.08 27.2496 1161.42 27.1001 1164.73 27.1998C1164.63 24.3101 1164.81 21.3706 1164.73 18.481V18.4561Z" fill="#C080FF"/>
<path d="M1173.45 45.882H1182.17V37.1383C1179.28 37.0636 1176.34 37.238 1173.45 37.1383C1173.55 40.028 1173.38 42.9674 1173.45 45.8571V45.882Z" fill="#C080FF"/>
<path d="M1182.17 27.2017V18.458H1173.43C1173.35 21.3477 1173.53 24.2872 1173.43 27.1768C1176.32 27.0772 1179.26 27.2515 1182.15 27.1768L1182.17 27.2017Z" fill="#C080FF"/>
<path d="M1154.77 54.5771V63.3208H1164.73V54.5771H1154.77Z" fill="#C080FF"/>
<path d="M1154.77 72.0156V80.7594H1164.73V72.0156H1154.77Z" fill="#C080FF"/>
<path d="M1154.77 89.4502V98.1939H1164.73V89.4502H1154.77Z" fill="#C080FF"/>
<path d="M1154.77 125.573V134.317H1164.73V125.573H1154.77Z" fill="#C080FF"/>
<path d="M1154.77 196.57V205.314H1164.73V196.57H1154.77Z" fill="#C080FF"/>
<path d="M1137.33 427.02V436.984H1146.07V427.02H1137.33Z" fill="#C080FF"/>
<path d="M1119.89 160.472V170.436H1128.63V160.472H1119.89Z" fill="#C080FF"/>
<path d="M1083.76 160.472V170.436H1092.51V160.472H1083.76Z" fill="#C080FF"/>
<path d="M1048.88 160.472V170.436H1057.63V160.472H1048.88Z" fill="#C080FF"/>
<path d="M1012.76 160.472V170.436H1021.5V160.472H1012.76Z" fill="#C080FF"/>
<path d="M995.319 427.02V436.984H1004.06V427.02H995.319Z" fill="#C080FF"/>
<path d="M959.189 1.01953V9.76324H969.159V1.01953H959.189Z" fill="#C080FF"/>
<path d="M959.189 18.458V27.2017H969.159V18.458H959.189Z" fill="#C080FF"/>
<path d="M959.189 37.1387V45.8824H969.159V37.1387H959.189Z" fill="#C080FF"/>
<path d="M959.189 54.5771V63.3208H969.159V54.5771H959.189Z" fill="#C080FF"/>
<path d="M959.189 72.0156V80.7594H969.159V72.0156H959.189Z" fill="#C080FF"/>
<path d="M959.189 89.4502V98.1939H969.159V89.4502H959.189Z" fill="#C080FF"/>
<path d="M959.189 108.135V116.879H969.159V108.135H959.189Z" fill="#C080FF"/>
<path d="M959.189 143.008V151.752H969.159V143.008H959.189Z" fill="#C080FF"/>
<path d="M959.189 179.131V187.875H969.159V179.131H959.189Z" fill="#C080FF"/>
<path d="M959.189 214.004V222.748H969.159V214.004H959.189Z" fill="#C080FF"/>
<path d="M959.189 250.128V258.871H969.159V250.128H959.189Z" fill="#C080FF"/>
<path d="M941.749 160.472V170.436H950.499V160.472H941.749Z" fill="#C080FF"/>
<path d="M888.189 1.01953V9.76324H898.159V1.01953H888.189Z" fill="#C080FF"/>
<path d="M888.189 18.458V27.2017H898.159V18.458H888.189Z" fill="#C080FF"/>
<path d="M888.189 37.1387V45.8824H898.159V37.1387H888.189Z" fill="#C080FF"/>
<path d="M888.189 72.0156V80.7594H898.159V72.0156H888.189Z" fill="#C080FF"/>
<path d="M888.189 108.135V116.879H898.159V108.135H888.189Z" fill="#C080FF"/>
<path d="M888.189 143.008V151.752H898.159V143.008H888.189Z" fill="#C080FF"/>
<path d="M888.189 179.131V187.875H898.159V179.131H888.189Z" fill="#C080FF"/>
<path d="M888.189 250.128V258.871H898.159V250.128H888.189Z" fill="#C080FF"/>
<path d="M763.619 18.458V27.2017H773.579V18.458H763.619Z" fill="#C080FF"/>
<path d="M763.619 54.5771V63.3208H773.579V54.5771H763.619Z" fill="#C080FF"/>
<path d="M763.619 125.573V134.317H773.579V125.573H763.619Z" fill="#C080FF"/>
<path d="M426.029 143.008V151.752H435.999V143.008H426.029Z" fill="#C080FF"/>
<path d="M426.029 72.0156V80.7594H435.999V72.0156H426.029Z" fill="#C080FF"/>
<path d="M426.029 37.1387V45.8824H435.999V37.1387H426.029Z" fill="#C080FF"/>
<path d="M426.029 1.01953V9.76324H435.999V1.01953H426.029Z" fill="#C080FF"/>
<path d="M692.609 18.458V27.2017H702.579V18.458H692.609Z" fill="#C080FF"/>
<path d="M692.609 54.5771V63.3208H702.579V54.5771H692.609Z" fill="#C080FF"/>
<path d="M692.609 125.573V134.317H702.579V125.573H692.609Z" fill="#C080FF"/>
<path d="M497.039 214.004V222.748H506.999V214.004H497.039Z" fill="#C080FF"/>
<path d="M497.039 143.008V151.752H506.999V143.008H497.039Z" fill="#C080FF"/>
<path d="M497.039 72.0156V80.7594H506.999V72.0156H497.039Z" fill="#C080FF"/>
<path d="M497.039 37.1387V45.8824H506.999V37.1387H497.039Z" fill="#C080FF"/>
<path d="M497.039 1.01953V9.76324H506.999V1.01953H497.039Z" fill="#C080FF"/>
<path d="M515.719 18.458V27.2017H524.439V18.458H515.719Z" fill="#C080FF"/>
<path d="M533.159 108.135V116.879H541.879V108.135H533.159Z" fill="#C080FF"/>
<path d="M533.159 72.0156V80.7594H541.879V72.0156H533.159Z" fill="#C080FF"/>
<path d="M533.159 37.1387V45.8824H541.879V37.1387H533.159Z" fill="#C080FF"/>
<path d="M533.159 1.01953V9.76324H541.879V1.01953H533.159Z" fill="#C080FF"/>
<path d="M550.599 54.5771V63.3208H559.319V54.5771H550.599Z" fill="#C080FF"/>
<path d="M569.289 285.001V293.744H578.009V285.001H569.289Z" fill="#C080FF"/>
<path d="M569.289 214.004V222.748H578.009V214.004H569.289Z" fill="#C080FF"/>
<path d="M569.289 143.008V151.752H578.009V143.008H569.289Z" fill="#C080FF"/>
<path d="M569.289 108.135V116.879H578.009V108.135H569.289Z" fill="#C080FF"/>
<path d="M569.289 72.0156V80.7594H578.009V72.0156H569.289Z" fill="#C080FF"/>
<path d="M569.289 37.1387V45.8824H578.009V37.1387H569.289Z" fill="#C080FF"/>
<path d="M569.289 1.01953V9.76324H578.009V1.01953H569.289Z" fill="#C080FF"/>
<path d="M586.729 18.458V27.2017H595.449V18.458H586.729Z" fill="#C080FF"/>
<path d="M604.169 179.131V187.875H612.889V179.131H604.169Z" fill="#C080FF"/>
<path d="M604.169 108.135V116.879H612.889V108.135H604.169Z" fill="#C080FF"/>
<path d="M604.169 72.0156V80.7594H612.889V72.0156H604.169Z" fill="#C080FF"/>
<path d="M604.169 37.1387V45.8824H612.889V37.1387H604.169Z" fill="#C080FF"/>
<path d="M604.169 1.01953V9.76324H612.889V1.01953H604.169Z" fill="#C080FF"/>
<path d="M621.609 54.5771V63.3208H630.329V54.5771H621.609Z" fill="#C080FF"/>
<path d="M621.609 18.458V27.2017H630.329V18.458H621.609Z" fill="#C080FF"/>
<path d="M462.149 1.01953V9.76324H470.869V1.01953H462.149Z" fill="#C080FF"/>
<path d="M462.149 37.1387V45.8824H470.869V37.1387H462.149Z" fill="#C080FF"/>
<path d="M462.149 108.135V116.879H470.869V108.135H462.149Z" fill="#C080FF"/>
<path d="M640.289 214.004V222.748H649.009V214.004H640.289Z" fill="#C080FF"/>
<path d="M640.289 179.131V187.875H649.009V179.131H640.289Z" fill="#C080FF"/>
<path d="M640.289 143.008V151.752H649.009V143.008H640.289Z" fill="#C080FF"/>
<path d="M640.289 108.135V116.879H649.009V108.135H640.289Z" fill="#C080FF"/>
<path d="M640.289 72.0156V80.7594H649.009V72.0156H640.289Z" fill="#C080FF"/>
<path d="M640.289 37.1387V45.8824H649.009V37.1387H640.289Z" fill="#C080FF"/>
<path d="M640.289 1.01953V9.76324H649.009V1.01953H640.289Z" fill="#C080FF"/>
<path d="M657.729 54.5771V63.3208H666.449V54.5771H657.729Z" fill="#C080FF"/>
<path d="M657.729 18.458V27.2017H666.449V18.458H657.729Z" fill="#C080FF"/>
<path d="M675.169 179.131V187.875H683.889V179.131H675.169Z" fill="#C080FF"/>
<path d="M675.169 108.135V116.879H683.889V108.135H675.169Z" fill="#C080FF"/>
<path d="M675.169 72.0156V80.7594H683.889V72.0156H675.169Z" fill="#C080FF"/>
<path d="M675.169 37.1387V45.8824H683.889V37.1387H675.169Z" fill="#C080FF"/>
<path d="M675.169 1.01953V9.76324H683.889V1.01953H675.169Z" fill="#C080FF"/>
<path d="M711.299 285.001V293.744H720.019V285.001H711.299Z" fill="#C080FF"/>
<path d="M711.299 214.004V222.748H720.019V214.004H711.299Z" fill="#C080FF"/>
<path d="M711.299 179.131V187.875H720.019V179.131H711.299Z" fill="#C080FF"/>
<path d="M711.299 143.008V151.752H720.019V143.008H711.299Z" fill="#C080FF"/>
<path d="M711.299 108.135V116.879H720.019V108.135H711.299Z" fill="#C080FF"/>
<path d="M711.299 72.0156V80.7594H720.019V72.0156H711.299Z" fill="#C080FF"/>
<path d="M711.299 37.1387V45.8824H720.019V37.1387H711.299Z" fill="#C080FF"/>
<path d="M711.299 1.01953V9.76324H720.019V1.01953H711.299Z" fill="#C080FF"/>
<path d="M728.739 89.4502V98.1939H737.459V89.4502H728.739Z" fill="#C080FF"/>
<path d="M728.739 54.5771V63.3208H737.459V54.5771H728.739Z" fill="#C080FF"/>
<path d="M728.739 18.458V27.2017H737.459V18.458H728.739Z" fill="#C080FF"/>
<path d="M746.179 179.131V187.875H754.899V179.131H746.179Z" fill="#C080FF"/>
<path d="M746.179 143.008V151.752H754.899V143.008H746.179Z" fill="#C080FF"/>
<path d="M746.179 108.135V116.879H754.899V108.135H746.179Z" fill="#C080FF"/>
<path d="M746.179 72.0156V80.7594H754.899V72.0156H746.179Z" fill="#C080FF"/>
<path d="M746.179 37.1387V45.8824H754.899V37.1387H746.179Z" fill="#C080FF"/>
<path d="M746.179 18.458V27.2017H754.899V18.458H746.179Z" fill="#C080FF"/>
<path d="M746.179 1.01953V9.76324H754.899V1.01953H746.179Z" fill="#C080FF"/>
<path d="M391.149 1.01953V9.76324H399.869V1.01953H391.149Z" fill="#C080FF"/>
<path d="M391.149 37.1387V45.8824H399.869V37.1387H391.149Z" fill="#C080FF"/>
<path d="M391.149 108.135V116.879H399.869V108.135H391.149Z" fill="#C080FF"/>
<path d="M782.299 285.001V293.744H791.019V285.001H782.299Z" fill="#C080FF"/>
<path d="M782.299 214.004V222.748H791.019V214.004H782.299Z" fill="#C080FF"/>
<path d="M782.299 179.131V187.875H791.019V179.131H782.299Z" fill="#C080FF"/>
<path d="M782.299 143.008V151.752H791.019V143.008H782.299Z" fill="#C080FF"/>
<path d="M782.299 108.135V116.879H791.019V108.135H782.299Z" fill="#C080FF"/>
<path d="M782.299 72.0156V80.7594H791.019V72.0156H782.299Z" fill="#C080FF"/>
<path d="M782.299 54.5771V63.3208H791.019V54.5771H782.299Z" fill="#C080FF"/>
<path d="M782.299 37.1387V45.8824H791.019V37.1387H782.299Z" fill="#C080FF"/>
<path d="M782.299 18.458V27.2017H791.019V18.458H782.299Z" fill="#C080FF"/>
<path d="M782.299 1.01953V9.76324H791.019V1.01953H782.299Z" fill="#C080FF"/>
<path d="M799.739 89.4502V98.1939H808.459V89.4502H799.739Z" fill="#C080FF"/>
<path d="M799.739 54.5771V63.3208H808.459V54.5771H799.739Z" fill="#C080FF"/>
<path d="M799.739 18.458V27.2017H808.459V18.458H799.739Z" fill="#C080FF"/>
<path d="M817.179 250.128V258.871H825.899V250.128H817.179Z" fill="#C080FF"/>
<path d="M817.179 179.131V187.875H825.899V179.131H817.179Z" fill="#C080FF"/>
<path d="M817.179 143.008V151.752H825.899V143.008H817.179Z" fill="#C080FF"/>
<path d="M817.179 108.135V116.879H825.899V108.135H817.179Z" fill="#C080FF"/>
<path d="M817.179 72.0156V80.7594H825.899V72.0156H817.179Z" fill="#C080FF"/>
<path d="M817.179 37.1387V45.8824H825.899V37.1387H817.179Z" fill="#C080FF"/>
<path d="M817.179 18.458V27.2017H825.899V18.458H817.179Z" fill="#C080FF"/>
<path d="M817.179 1.01953V9.76324H825.899V1.01953H817.179Z" fill="#C080FF"/>
<path d="M355.029 1.01953V9.76324H363.749V1.01953H355.029Z" fill="#C080FF"/>
<path d="M355.029 37.1387V45.8824H363.749V37.1387H355.029Z" fill="#C080FF"/>
<path d="M355.029 72.0156V80.7594H363.749V72.0156H355.029Z" fill="#C080FF"/>
<path d="M355.029 143.008V151.752H363.749V143.008H355.029Z" fill="#C080FF"/>
<path d="M355.029 214.004V222.748H363.749V214.004H355.029Z" fill="#C080FF"/>
<path d="M835.869 125.573V134.317H844.589V125.573H835.869Z" fill="#C080FF"/>
<path d="M835.869 89.4502V98.1939H844.589V89.4502H835.869Z" fill="#C080FF"/>
<path d="M835.869 54.5771V63.3208H844.589V54.5771H835.869Z" fill="#C080FF"/>
<path d="M835.869 18.458V27.2017H844.589V18.458H835.869Z" fill="#C080FF"/>
<path d="M853.309 285.001V293.744H862.029V285.001H853.309Z" fill="#C080FF"/>
<path d="M853.309 214.004V222.748H862.029V214.004H853.309Z" fill="#C080FF"/>
<path d="M853.309 179.131V187.875H862.029V179.131H853.309Z" fill="#C080FF"/>
<path d="M853.309 143.008V151.752H862.029V143.008H853.309Z" fill="#C080FF"/>
<path d="M853.309 108.135V116.879H862.029V108.135H853.309Z" fill="#C080FF"/>
<path d="M853.309 72.0156V80.7594H862.029V72.0156H853.309Z" fill="#C080FF"/>
<path d="M853.309 54.5771V63.3208H862.029V54.5771H853.309Z" fill="#C080FF"/>
<path d="M853.309 37.1387V45.8824H862.029V37.1387H853.309Z" fill="#C080FF"/>
<path d="M853.309 18.458V27.2017H862.029V18.458H853.309Z" fill="#C080FF"/>
<path d="M853.309 1.01953V9.76324H862.029V1.01953H853.309Z" fill="#C080FF"/>
<path d="M870.749 125.573V134.317H879.469V125.573H870.749Z" fill="#C080FF"/>
<path d="M870.749 89.4502V98.1939H879.469V89.4502H870.749Z" fill="#C080FF"/>
<path d="M870.749 54.5771V63.3208H879.469V54.5771H870.749Z" fill="#C080FF"/>
<path d="M870.749 18.458V27.2017H879.469V18.458H870.749Z" fill="#C080FF"/>
<path d="M870.749 1.01953V9.76324H879.469V1.01953H870.749Z" fill="#C080FF"/>
<path d="M906.869 196.57V205.314H915.589V196.57H906.869Z" fill="#C080FF"/>
<path d="M906.869 125.573V134.317H915.589V125.573H906.869Z" fill="#C080FF"/>
<path d="M906.869 89.4502V98.1939H915.589V89.4502H906.869Z" fill="#C080FF"/>
<path d="M906.869 54.5771V63.3208H915.589V54.5771H906.869Z" fill="#C080FF"/>
<path d="M906.869 37.1387V45.8824H915.589V37.1387H906.869Z" fill="#C080FF"/>
<path d="M906.869 18.458V27.2017H915.589V18.458H906.869Z" fill="#C080FF"/>
<path d="M320.149 1.01953V9.76324H328.869V1.01953H320.149Z" fill="#C080FF"/>
<path d="M320.149 37.1387V45.8824H328.869V37.1387H320.149Z" fill="#C080FF"/>
<path d="M924.309 355.997V364.741H933.029V355.997H924.309Z" fill="#C080FF"/>
<path d="M924.309 285.001V293.744H933.029V285.001H924.309Z" fill="#C080FF"/>
<path d="M924.309 214.004V222.748H933.029V214.004H924.309Z" fill="#C080FF"/>
<path d="M924.309 179.131V187.875H933.029V179.131H924.309Z" fill="#C080FF"/>
<path d="M924.309 143.008V151.752H933.029V143.008H924.309Z" fill="#C080FF"/>
<path d="M924.309 108.135V116.879H933.029V108.135H924.309Z" fill="#C080FF"/>
<path d="M924.309 72.0156V80.7594H933.029V72.0156H924.309Z" fill="#C080FF"/>
<path d="M924.309 54.5771V63.3208H933.029V54.5771H924.309Z" fill="#C080FF"/>
<path d="M924.309 37.1387V45.8824H933.029V37.1387H924.309Z" fill="#C080FF"/>
<path d="M924.309 18.458V27.2017H933.029V18.458H924.309Z" fill="#C080FF"/>
<path d="M924.309 1.01953V9.76324H933.029V1.01953H924.309Z" fill="#C080FF"/>
<path d="M941.749 125.573V134.317H950.469V125.573H941.749Z" fill="#C080FF"/>
<path d="M941.749 89.4502V98.1939H950.469V89.4502H941.749Z" fill="#C080FF"/>
<path d="M941.749 54.5771V63.3208H950.469V54.5771H941.749Z" fill="#C080FF"/>
<path d="M941.749 18.458V27.2017H950.469V18.458H941.749Z" fill="#C080FF"/>
<path d="M941.749 1.01953V9.76324H950.469V1.01953H941.749Z" fill="#C080FF"/>
<path d="M284.019 1.01953V9.76324H292.739V1.01953H284.019Z" fill="#C080FF"/>
<path d="M284.019 72.0156V80.7594H292.739V72.0156H284.019Z" fill="#C080FF"/>
<path d="M284.019 143.008V151.752H292.739V143.008H284.019Z" fill="#C080FF"/>
<path d="M977.879 196.57V205.314H986.599V196.57H977.879Z" fill="#C080FF"/>
<path d="M977.879 125.573V134.317H986.599V125.573H977.879Z" fill="#C080FF"/>
<path d="M977.879 89.4502V98.1939H986.599V89.4502H977.879Z" fill="#C080FF"/>
<path d="M977.879 54.5771V63.3208H986.599V54.5771H977.879Z" fill="#C080FF"/>
<path d="M977.879 37.1387V45.8824H986.599V37.1387H977.879Z" fill="#C080FF"/>
<path d="M977.879 18.458V27.2017H986.599V18.458H977.879Z" fill="#C080FF"/>
<path d="M977.879 1.0446V9.76338H986.619C986.699 6.87373 986.519 3.93425 986.619 1.0446C983.729 1.14425 980.789 0.969872 977.899 1.0446H977.879Z" fill="#C080FF"/>
<path d="M995.319 9.76292H1004.04V1.01921C1001.15 0.944482 998.209 1.11885 995.319 1.01921C995.419 3.90886 995.239 6.84833 995.319 9.73798V9.76292Z" fill="#C080FF"/>
<path d="M995.319 355.997V364.741H1004.04V355.997H995.319Z" fill="#C080FF"/>
<path d="M995.319 285.001V293.744H1004.04V285.001H995.319Z" fill="#C080FF"/>
<path d="M995.319 250.128V258.871H1004.04V250.128H995.319Z" fill="#C080FF"/>
<path d="M995.319 214.004V222.748H1004.04V214.004H995.319Z" fill="#C080FF"/>
<path d="M995.319 179.131V187.875H1004.04V179.131H995.319Z" fill="#C080FF"/>
<path d="M995.319 143.008V151.752H1004.04V143.008H995.319Z" fill="#C080FF"/>
<path d="M995.319 125.573V134.317H1004.04V125.573H995.319Z" fill="#C080FF"/>
<path d="M995.319 108.135V116.879H1004.04V108.135H995.319Z" fill="#C080FF"/>
<path d="M995.319 89.4502V98.1939H1004.04V89.4502H995.319Z" fill="#C080FF"/>
<path d="M995.319 72.0156V80.7594H1004.04V72.0156H995.319Z" fill="#C080FF"/>
<path d="M995.319 54.5771V63.3208H1004.04V54.5771H995.319Z" fill="#C080FF"/>
<path d="M995.319 37.1387V45.8824H1004.04V37.1387H995.319Z" fill="#C080FF"/>
<path d="M995.319 18.458V27.2017H1004.04V18.458H995.319Z" fill="#C080FF"/>
<path d="M1012.76 125.573V134.317H1021.48V125.573H1012.76Z" fill="#C080FF"/>
<path d="M1012.76 89.4502V98.1939H1021.48V89.4502H1012.76Z" fill="#C080FF"/>
<path d="M1012.76 54.5771V63.3208H1021.48V54.5771H1012.76Z" fill="#C080FF"/>
<path d="M1012.76 18.458V27.2017H1021.48V18.458H1012.76Z" fill="#C080FF"/>
<path d="M1012.76 1.01953V9.76324H1021.48V1.01953H1012.76Z" fill="#C080FF"/>
<path d="M1031.44 321.124V329.868H1040.16V321.124H1031.44Z" fill="#C080FF"/>
<path d="M1031.44 250.128V258.871H1040.16V250.128H1031.44Z" fill="#C080FF"/>
<path d="M1031.44 214.004V222.748H1040.16V214.004H1031.44Z" fill="#C080FF"/>
<path d="M1031.44 179.131V187.875H1040.16V179.131H1031.44Z" fill="#C080FF"/>
<path d="M1031.44 143.008V151.752H1040.16V143.008H1031.44Z" fill="#C080FF"/>
<path d="M1031.44 108.135V116.879H1040.16V108.135H1031.44Z" fill="#C080FF"/>
<path d="M1031.44 89.4502V98.1939H1040.16V89.4502H1031.44Z" fill="#C080FF"/>
<path d="M1031.44 72.0156V80.7594H1040.16V72.0156H1031.44Z" fill="#C080FF"/>
<path d="M1031.44 54.5771V63.3208H1040.16V54.5771H1031.44Z" fill="#C080FF"/>
<path d="M1031.44 37.1387V45.8824H1040.16V37.1387H1031.44Z" fill="#C080FF"/>
<path d="M1031.44 18.458V27.2017H1040.16V18.458H1031.44Z" fill="#C080FF"/>
<path d="M1031.44 1.01953V9.76324H1040.16V1.01953H1031.44Z" fill="#C080FF"/>
<path d="M1048.88 196.57V205.314H1057.6V196.57H1048.88Z" fill="#C080FF"/>
<path d="M1048.88 125.573V134.317H1057.6V125.573H1048.88Z" fill="#C080FF"/>
<path d="M1048.88 89.4502V98.1939H1057.6V89.4502H1048.88Z" fill="#C080FF"/>
<path d="M1048.88 54.5771V63.3208H1057.6V54.5771H1048.88Z" fill="#C080FF"/>
<path d="M1048.88 37.1387V45.8824H1057.6V37.1387H1048.88Z" fill="#C080FF"/>
<path d="M1048.88 18.458V27.2017H1057.6V18.458H1048.88Z" fill="#C080FF"/>
<path d="M1048.88 1.0446V9.76338H1057.63C1057.7 6.87373 1057.53 3.93425 1057.63 1.0446C1054.74 1.14425 1051.8 0.969872 1048.91 1.0446H1048.88Z" fill="#C080FF"/>
<path d="M1066.32 9.76292H1075.04V1.01921C1072.15 0.944482 1069.21 1.11885 1066.32 1.01921C1066.42 3.90886 1066.25 6.84833 1066.32 9.73798V9.76292Z" fill="#C080FF"/>
<path d="M1066.32 355.997V364.741H1075.04V355.997H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 285.001V293.744H1075.04V285.001H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 250.128V258.871H1075.04V250.128H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 214.004V222.748H1075.04V214.004H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 179.131V187.875H1075.04V179.131H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 143.008V151.752H1075.04V143.008H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 125.573V134.317H1075.04V125.573H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 108.135V116.879H1075.04V108.135H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 89.4502V98.1939H1075.04V89.4502H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 72.0156V80.7594H1075.04V72.0156H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 54.5771V63.3208H1075.04V54.5771H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 37.1387V45.8824H1075.04V37.1387H1066.32Z" fill="#C080FF"/>
<path d="M1066.32 18.458V27.2017H1075.04V18.458H1066.32Z" fill="#C080FF"/>
<path d="M249.139 37.1387V45.8824H257.859V37.1387H249.139Z" fill="#C080FF"/>
<path d="M1083.76 196.57V205.314H1092.48V196.57H1083.76Z" fill="#C080FF"/>
<path d="M1083.76 125.573V134.317H1092.48V125.573H1083.76Z" fill="#C080FF"/>
<path d="M1083.76 89.4502V98.1939H1092.48V89.4502H1083.76Z" fill="#C080FF"/>
<path d="M1083.76 72.0156V80.7594H1092.48V72.0156H1083.76Z" fill="#C080FF"/>
<path d="M1083.76 54.5771V63.3208H1092.48V54.5771H1083.76Z" fill="#C080FF"/>
<path d="M1083.76 37.1387V45.8824H1092.48V37.1387H1083.76Z" fill="#C080FF"/>
<path d="M1083.76 18.458V27.2017H1092.48V18.458H1083.76Z" fill="#C080FF"/>
<path d="M1083.76 1.01953V9.76324H1092.48V1.01953H1083.76Z" fill="#C080FF"/>
<path d="M1102.45 321.124V329.868H1111.17V321.124H1102.45Z" fill="#C080FF"/>
<path d="M1102.45 250.128V258.871H1111.17V250.128H1102.45Z" fill="#C080FF"/>
<path d="M1102.45 214.004V222.748H1111.17V214.004H1102.45Z" fill="#C080FF"/>
<path d="M1102.45 179.131V187.875H1111.17V179.131H1102.45Z" fill="#C080FF"/>
<path d="M1102.45 143.008V151.752H1111.17V143.008H1102.45Z" fill="#C080FF"/>
<path d="M1102.45 108.135V116.879H1111.17V108.135H1102.45Z" fill="#C080FF"/>
<path d="M1102.45 89.4502V98.1939H1111.17V89.4502H1102.45Z" fill="#C080FF"/>
<path d="M1102.45 72.0156V80.7594H1111.17V72.0156H1102.45Z" fill="#C080FF"/>
<path d="M1102.45 54.5771V63.3208H1111.17V54.5771H1102.45Z" fill="#C080FF"/>
<path d="M1102.45 37.1387V45.8824H1111.17V37.1387H1102.45Z" fill="#C080FF"/>
<path d="M1102.45 18.458V27.2017H1111.17V18.458H1102.45Z" fill="#C080FF"/>
<path d="M1102.45 1.01953V9.76324H1111.17V1.01953H1102.45Z" fill="#C080FF"/>
<path d="M1119.89 196.57V205.314H1128.61V196.57H1119.89Z" fill="#C080FF"/>
<path d="M1119.89 125.573V134.317H1128.61V125.573H1119.89Z" fill="#C080FF"/>
<path d="M1119.89 89.4502V98.1939H1128.61V89.4502H1119.89Z" fill="#C080FF"/>
<path d="M1119.89 54.5771V63.3208H1128.61V54.5771H1119.89Z" fill="#C080FF"/>
<path d="M1119.89 37.1387V45.8824H1128.61V37.1387H1119.89Z" fill="#C080FF"/>
<path d="M1119.89 18.458V27.2017H1128.61V18.458H1119.89Z" fill="#C080FF"/>
<path d="M213.009 1.01953V9.76324H221.729V1.01953H213.009Z" fill="#C080FF"/>
<path d="M213.009 72.0156V80.7594H221.729V72.0156H213.009Z" fill="#C080FF"/>
<path d="M1137.33 355.997V364.741H1146.05V355.997H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 285.001V293.744H1146.05V285.001H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 250.128V258.871H1146.05V250.128H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 214.004V222.748H1146.05V214.004H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 179.131V187.875H1146.05V179.131H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 143.008V151.752H1146.05V143.008H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 125.573V134.317H1146.05V125.573H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 108.135V116.879H1146.05V108.135H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 89.4502V98.1939H1146.05V89.4502H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 72.0156V80.7594H1146.05V72.0156H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 54.5771V63.3208H1146.05V54.5771H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 37.1387V45.8824H1146.05V37.1387H1137.33Z" fill="#C080FF"/>
<path d="M1137.33 18.458V27.2017H1146.05V18.458H1137.33Z" fill="#C080FF"/>
<path d="M1173.45 321.124V329.868H1182.17V321.124H1173.45Z" fill="#C080FF"/>
<path d="M1173.45 285.001V293.744H1182.17V285.001H1173.45Z" fill="#C080FF"/>
<path d="M1173.45 250.128V258.871H1182.17V250.128H1173.45Z" fill="#C080FF"/>
<path d="M1173.45 214.004V222.748H1182.17V214.004H1173.45Z" fill="#C080FF"/>
<path d="M1173.45 179.131V187.875H1182.17V179.131H1173.45Z" fill="#C080FF"/>
<path d="M1173.45 143.008V151.752H1182.17V143.008H1173.45Z" fill="#C080FF"/>
<path d="M1173.45 125.573V134.317H1182.17V125.573H1173.45Z" fill="#C080FF"/>
<path d="M1173.45 108.135V116.879H1182.17V108.135H1173.45Z" fill="#C080FF"/>
<path d="M1173.45 89.4502V98.1939H1182.17V89.4502H1173.45Z" fill="#C080FF"/>
<path d="M1173.45 72.0156V80.7594H1182.17V72.0156H1173.45Z" fill="#C080FF"/>
<path d="M1173.45 54.5771V63.3208H1182.17V54.5771H1173.45Z" fill="#C080FF"/>
<path d="M1190.89 267.562V276.306H1199.61V267.562H1190.89Z" fill="#C080FF"/>
<path d="M1190.89 196.57V205.314H1199.61V196.57H1190.89Z" fill="#C080FF"/>
<path d="M1190.89 125.573V134.317H1199.61V125.573H1190.89Z" fill="#C080FF"/>
<path d="M1190.89 108.135V116.879H1199.61V108.135H1190.89Z" fill="#C080FF"/>
<path d="M1190.89 89.4502V98.1939H1199.61V89.4502H1190.89Z" fill="#C080FF"/>
<path d="M1199.61 54.5791H1190.89V63.3228C1193.78 63.3975 1196.72 63.2231 1199.61 63.3228C1199.51 60.4331 1199.69 57.4936 1199.61 54.604V54.5791Z" fill="#C080FF"/>
<path d="M1217.05 63.3208V54.5771H1208.31C1208.23 57.4667 1208.41 60.4062 1208.31 63.2958C1211.2 63.1962 1214.14 63.3706 1217.03 63.2958L1217.05 63.3208Z" fill="#C080FF"/>
<path d="M1208.33 80.7591H1217.05V72.0153C1214.16 71.9406 1211.22 72.115 1208.33 72.0153C1208.43 74.905 1208.26 77.8445 1208.33 80.7341V80.7591Z" fill="#C080FF"/>
<path d="M1190.89 72.0407V80.7595H1199.64C1199.71 77.8698 1199.54 74.9303 1199.64 72.0407C1196.75 72.1403 1193.81 71.966 1190.92 72.0407H1190.89Z" fill="#C080FF"/>
<path d="M1208.33 63.3193C1205.44 63.4189 1202.5 63.4189 1199.61 63.3193C1199.71 66.209 1199.71 69.1484 1199.61 72.0381C1202.5 71.9384 1205.44 71.9384 1208.33 72.0381C1208.23 69.1484 1208.23 66.209 1208.33 63.3193Z" fill="#C080FF"/>
<path d="M1208.33 355.997V364.741H1217.05V355.997H1208.33Z" fill="#C080FF"/>
<path d="M1208.33 321.124V329.868H1217.05V321.124H1208.33Z" fill="#C080FF"/>
<path d="M1208.33 285.001V293.744H1217.05V285.001H1208.33Z" fill="#C080FF"/>
<path d="M1208.33 250.128V258.871H1217.05V250.128H1208.33Z" fill="#C080FF"/>
<path d="M1208.33 214.004V222.748H1217.05V214.004H1208.33Z" fill="#C080FF"/>
<path d="M1208.33 196.57V205.314H1217.05V196.57H1208.33Z" fill="#C080FF"/>
<path d="M1208.33 179.131V187.875H1217.05V179.131H1208.33Z" fill="#C080FF"/>
<path d="M1208.33 143.008V151.752H1217.05V143.008H1208.33Z" fill="#C080FF"/>
<path d="M1208.33 125.573V134.317H1217.05V125.573H1208.33Z" fill="#C080FF"/>
<path d="M1208.33 108.135V116.879H1217.05V108.135H1208.33Z" fill="#C080FF"/>
<path d="M1208.33 89.4502V98.1939H1217.05V89.4502H1208.33Z" fill="#C080FF"/>
<path d="M1244.46 392.116V400.859H1253.18V392.116H1244.46Z" fill="#C080FF"/>
<path d="M1244.46 321.124V329.868H1253.18V321.124H1244.46Z" fill="#C080FF"/>
<path d="M1244.46 285.001V293.744H1253.18V285.001H1244.46Z" fill="#C080FF"/>
<path d="M1244.46 250.128V258.871H1253.18V250.128H1244.46Z" fill="#C080FF"/>
<path d="M1244.46 214.004V222.748H1253.18V214.004H1244.46Z" fill="#C080FF"/>
<path d="M1244.46 179.131V187.875H1253.18V179.131H1244.46Z" fill="#C080FF"/>
<path d="M1244.46 143.008V151.752H1253.18V143.008H1244.46Z" fill="#C080FF"/>
<path d="M1244.46 125.573V134.317H1253.18V125.573H1244.46Z" fill="#C080FF"/>
<path d="M1244.46 108.135V116.879H1253.18V108.135H1244.46Z" fill="#C080FF"/>
<path d="M1244.46 89.4502V98.1939H1253.18V89.4502H1244.46Z" fill="#C080FF"/>
<path d="M1244.46 72.0156V80.7594H1253.18V72.0156H1244.46Z" fill="#C080FF"/>
<path d="M1244.46 54.5771V63.3208H1253.18V54.5771H1244.46Z" fill="#C080FF"/>
<path d="M142.009 1.01953V9.76324H150.729V1.01953H142.009Z" fill="#C080FF"/>
<path d="M1261.9 267.562V276.306H1270.62V267.562H1261.9Z" fill="#C080FF"/>
<path d="M1261.9 196.57V205.314H1270.62V196.57H1261.9Z" fill="#C080FF"/>
<path d="M1261.9 125.573V134.317H1270.62V125.573H1261.9Z" fill="#C080FF"/>
<path d="M1261.9 108.135V116.879H1270.62V108.135H1261.9Z" fill="#C080FF"/>
<path d="M1261.9 89.4502V98.1939H1270.62V89.4502H1261.9Z" fill="#C080FF"/>
<path d="M1279.34 63.3193C1276.45 63.4189 1273.51 63.4189 1270.62 63.3193C1270.72 66.209 1270.72 69.1484 1270.62 72.0381C1273.51 71.9384 1276.45 71.9384 1279.34 72.0381C1279.24 69.1484 1279.24 66.209 1279.34 63.3193Z" fill="#C080FF"/>
<path d="M1279.34 80.7591H1288.06V72.0153C1285.17 71.9406 1282.23 72.115 1279.34 72.0153C1279.44 74.905 1279.26 77.8445 1279.34 80.7341V80.7591Z" fill="#C080FF"/>
<path d="M1288.06 63.3208V54.5771H1279.31C1279.24 57.4667 1279.41 60.4062 1279.31 63.2958C1282.2 63.1962 1285.14 63.3706 1288.03 63.2958L1288.06 63.3208Z" fill="#C080FF"/>
<path d="M1270.62 54.5791H1261.9V63.3228C1264.79 63.3975 1267.73 63.2231 1270.62 63.3228C1270.52 60.4331 1270.69 57.4936 1270.62 54.604V54.5791Z" fill="#C080FF"/>
<path d="M1261.9 72.0407V80.7595H1270.64C1270.72 77.8698 1270.54 74.9303 1270.64 72.0407C1267.75 72.1403 1264.81 71.966 1261.92 72.0407H1261.9Z" fill="#C080FF"/>
<path d="M1279.34 355.997V364.741H1288.06V355.997H1279.34Z" fill="#C080FF"/>
<path d="M1279.34 321.124V329.868H1288.06V321.124H1279.34Z" fill="#C080FF"/>
<path d="M1279.34 285.001V293.744H1288.06V285.001H1279.34Z" fill="#C080FF"/>
<path d="M1279.34 250.128V258.871H1288.06V250.128H1279.34Z" fill="#C080FF"/>
<path d="M1279.34 214.004V222.748H1288.06V214.004H1279.34Z" fill="#C080FF"/>
<path d="M1279.34 196.57V205.314H1288.06V196.57H1279.34Z" fill="#C080FF"/>
<path d="M1279.34 179.131V187.875H1288.06V179.131H1279.34Z" fill="#C080FF"/>
<path d="M1279.34 143.008V151.752H1288.06V143.008H1279.34Z" fill="#C080FF"/>
<path d="M1279.34 125.573V134.317H1288.06V125.573H1279.34Z" fill="#C080FF"/>
<path d="M1279.34 108.135V116.879H1288.06V108.135H1279.34Z" fill="#C080FF"/>
<path d="M1279.34 89.4502V98.1939H1288.06V89.4502H1279.34Z" fill="#C080FF"/>
<path d="M1298.02 267.562V276.306H1306.74V267.562H1298.02Z" fill="#C080FF"/>
<path d="M1298.02 196.57V205.314H1306.74V196.57H1298.02Z" fill="#C080FF"/>
<path d="M1298.02 143.008V151.752H1306.74V143.008H1298.02Z" fill="#C080FF"/>
<path d="M1298.02 125.573V134.317H1306.74V125.573H1298.02Z" fill="#C080FF"/>
<path d="M1298.02 108.135V116.879H1306.74V108.135H1298.02Z" fill="#C080FF"/>
<path d="M1298.02 89.4502V98.1939H1306.74V89.4502H1298.02Z" fill="#C080FF"/>
<path d="M1298.02 72.0156V80.7594H1306.74V72.0156H1298.02Z" fill="#C080FF"/>
<path d="M1298.02 54.5771V63.3208H1306.74V54.5771H1298.02Z" fill="#C080FF"/>
<path d="M1315.46 392.116V400.859H1324.18V392.116H1315.46Z" fill="#C080FF"/>
<path d="M1315.46 321.124V329.868H1324.18V321.124H1315.46Z" fill="#C080FF"/>
<path d="M1315.46 285.001V293.744H1324.18V285.001H1315.46Z" fill="#C080FF"/>
<path d="M1315.46 250.128V258.871H1324.18V250.128H1315.46Z" fill="#C080FF"/>
<path d="M1315.46 214.004V222.748H1324.18V214.004H1315.46Z" fill="#C080FF"/>
<path d="M1315.46 179.131V187.875H1324.18V179.131H1315.46Z" fill="#C080FF"/>
<path d="M1315.46 143.008V151.752H1324.18V143.008H1315.46Z" fill="#C080FF"/>
<path d="M1315.46 125.573V134.317H1324.18V125.573H1315.46Z" fill="#C080FF"/>
<path d="M1315.46 108.135V116.879H1324.18V108.135H1315.46Z" fill="#C080FF"/>
<path d="M1315.46 89.4502V98.1939H1324.18V89.4502H1315.46Z" fill="#C080FF"/>
<path d="M1315.46 72.0156V80.7594H1324.18V72.0156H1315.46Z" fill="#C080FF"/>
<path d="M1315.46 54.5771V63.3208H1324.18V54.5771H1315.46Z" fill="#C080FF"/>
<path d="M1332.9 267.562V276.306H1341.62V267.562H1332.9Z" fill="#C080FF"/>
<path d="M1332.9 196.57V205.314H1341.62V196.57H1332.9Z" fill="#C080FF"/>
<path d="M1332.9 125.573V134.317H1341.62V125.573H1332.9Z" fill="#C080FF"/>
<path d="M1332.9 108.135V116.879H1341.62V108.135H1332.9Z" fill="#C080FF"/>
<path d="M1332.9 89.4502V98.1939H1341.62V89.4502H1332.9Z" fill="#C080FF"/>
<path d="M71.0049 1.01953V9.76324H79.7249V1.01953H71.0049Z" fill="#C080FF"/>
<path d="M71.0049 72.0156V80.7594H79.7249V72.0156H71.0049Z" fill="#C080FF"/>
<path d="M1369.03 303.686V312.43H1377.75V303.686H1369.03Z" fill="#C080FF"/>
<path d="M1369.03 267.562V276.306H1377.75V267.562H1369.03Z" fill="#C080FF"/>
<path d="M1369.03 196.57V205.314H1377.75V196.57H1369.03Z" fill="#C080FF"/>
<path d="M1369.03 143.008V151.752H1377.75V143.008H1369.03Z" fill="#C080FF"/>
<path d="M1369.03 125.573V134.317H1377.75V125.573H1369.03Z" fill="#C080FF"/>
<path d="M1386.47 392.116V400.859H1395.19V392.116H1386.47Z" fill="#C080FF"/>
<path d="M1386.47 355.997V364.741H1395.19V355.997H1386.47Z" fill="#C080FF"/>
<path d="M1386.47 321.124V329.868H1395.19V321.124H1386.47Z" fill="#C080FF"/>
<path d="M1386.47 285.001V293.744H1395.19V285.001H1386.47Z" fill="#C080FF"/>
<path d="M1386.47 250.128V258.871H1395.19V250.128H1386.47Z" fill="#C080FF"/>
<path d="M1386.47 214.004V222.748H1395.19V214.004H1386.47Z" fill="#C080FF"/>
<path d="M1386.47 196.57V205.314H1395.19V196.57H1386.47Z" fill="#C080FF"/>
<path d="M1386.47 179.131V187.875H1395.19V179.131H1386.47Z" fill="#C080FF"/>
<path d="M1386.47 143.008V151.752H1395.19V143.008H1386.47Z" fill="#C080FF"/>
<path d="M1386.47 125.573V134.317H1395.19V125.573H1386.47Z" fill="#C080FF"/>
<path d="M1403.91 267.562V276.306H1412.66V267.562H1403.91Z" fill="#C080FF"/>
<path d="M1403.91 196.57V205.314H1412.66V196.57H1403.91Z" fill="#C080FF"/>
<path d="M1403.91 179.131V187.875H1412.66V179.131H1403.91Z" fill="#C080FF"/>
<path d="M1403.91 143.008V151.752H1412.66V143.008H1403.91Z" fill="#C080FF"/>
<path d="M1403.91 125.573V134.317H1412.66V125.573H1403.91Z" fill="#C080FF"/>
<path d="M0 1.01953V9.76324H8.72003V1.01953H0Z" fill="#C080FF"/>
</g>
<defs>
<clipPath id="clip0_414_125114">
<rect width="1421" height="507" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 49 KiB

View file

@ -16,19 +16,19 @@
<span class="input-group-btn" style="padding: 0px 5px">
<span style="margin: 0px 5px" authorization="DockerImagePush">
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Push to registry" ng-click="pushTag(tag)">
<pr-icon icon="'upload'" class="text-white"></pr-icon>
<pr-icon icon="'upload'"></pr-icon>
</a>
</span>
<span class="mx-1 my-0" authorization="DockerImageCreate">
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Pull from registry" ng-click="pullTag(tag)">
<pr-icon icon="'download'" class="text-white"></pr-icon>
<pr-icon icon="'download'"></pr-icon>
</a>
</span>
<span class="mx-1 my-0" authorization="DockerImageDelete">
<a data-toggle="tooltip" class="btn btn-primary interactive" title="Remove tag" ng-click="removeTag(tag)">
<pr-icon icon="'trash-2'" class="text-white"></pr-icon>
<pr-icon icon="'trash-2'"></pr-icon>
</a>
</span>
</span>

Some files were not shown because too many files have changed in this diff Show more