Compare commits

..

12 Commits

Author SHA1 Message Date
Ali e2833aa26e chore(version): bump version to 2.38.1 (#1842) 2026-02-12 16:29:18 +13:00
nickl-portainer 420488116b fix(environments): handle unix:// urls [BE-12610] (#1835)
Co-authored-by: Chaim Lev-Ari <chaim.lev-ari@portainer.io>
2026-02-10 15:21:11 +02:00
Ali bb3de163a6 feat(policy-RBAC): ensure RBAC policy overrides existing RBAC settings [R8S-777] (#1810) 2026-02-10 23:40:50 +13:00
Steven Kang 3cef57760b fix(policy): pod security constraints - release 2.38.1 [R8S-808] (#1759) 2026-02-10 08:46:08 +09:00
Josiah Clumont 7d49f61a05 fix(docker): Update the docker binary version that uses 1.25.6 to fix CVE-2025-61726 - for 2.38.1-STS Patch [R8S-818] (#1794) 2026-02-10 11:01:40 +13:00
Josiah Clumont 0b51ad7f01 fix(CVE): Updated Golang to 1.25.7 to resolve CVE-2025-61726 (#1831) 2026-02-10 08:46:23 +13:00
Chaim Lev-Ari 92527f1212 fix(environments): update associated group [BE-12559] (#1801) 2026-02-05 16:33:55 +02:00
nickl-portainer 1c903b35a6 feat(menu) move policies from observability to env settings [R8S-806] (#1778) 2026-02-05 10:09:37 +13:00
RHCowan 8a354ceceb fix(policy) Fetch new status after policy update [R8S-711] (#1775) (#1798) 2026-02-05 09:04:52 +13:00
nickl-portainer 7519e7cb89 fix(react): namespace selects sort alphabetically [R8S-765] (#1785) 2026-02-04 19:05:36 +13:00
RHCowan a33a72923d feat(policy): Display last attempt timestamp for policy installations [R8S-667] (#1774) (#1782) 2026-02-04 10:40:14 +13:00
Ali 8ddd2ade8b chore(environment-groups): migrate environment groups to react [R8S-771] (#1779) 2026-02-04 08:39:17 +13:00
209 changed files with 8544 additions and 8583 deletions
+2 -6
View File
@@ -139,18 +139,15 @@ overrides:
'react/jsx-props-no-spreading': off
- files:
- app/**/*.test.*
plugins:
- '@vitest'
extends:
- 'plugin:@vitest/legacy-recommended'
- 'plugin:vitest/recommended'
env:
'@vitest/env': true
'vitest/env': true
rules:
'react/jsx-no-constructed-context-values': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
'@vitest/no-conditional-expect': warn
- files:
- app/**/*.stories.*
rules:
@@ -158,4 +155,3 @@ overrides:
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
'storybook/no-renderer-packages': off
-3
View File
@@ -94,13 +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.38.1'
- '2.38.0'
- '2.37.0'
- '2.36.0'
- '2.35.0'
- '2.34.0'
- '2.33.7'
- '2.33.6'
- '2.33.5'
- '2.33.4'
+1 -1
View File
@@ -6,7 +6,7 @@ linters:
settings:
forbidigo:
forbid:
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
msg: Use a transaction instead
analyze-types: true
exclusions:
+1 -2
View File
@@ -1,3 +1,2 @@
dist
api/datastore/test_data
coverage
api/datastore/test_data
+11 -29
View File
@@ -9,38 +9,20 @@ const config: StorybookConfig = {
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-webpack5-compiler-swc',
'@chromatic-com/storybook',
{
name: '@storybook/addon-styling-webpack',
name: '@storybook/addon-styling',
options: {
rules: [
{
test: /\.css$/,
sideEffects: true,
use: [
require.resolve('style-loader'),
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
modules: {
localIdentName: '[path][name]__[local]',
auto: true,
exportLocalsConvention: 'camelCaseOnly',
},
},
},
{
loader: require.resolve('postcss-loader'),
options: {
implementation: postcss,
},
},
],
cssLoaderOptions: {
importLoaders: 1,
modules: {
localIdentName: '[path][name]__[local]',
auto: true,
exportLocalsConvention: 'camelCaseOnly',
},
],
},
postCss: {
implementation: postcss,
},
},
},
],
+18 -17
View File
@@ -1,9 +1,9 @@
import '../app/assets/css';
import React from 'react';
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
import { handlers } from '../app/setup-tests/server-handlers';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Preview } from '@storybook/react';
initMSW(
{
@@ -21,30 +21,31 @@ initMSW(
handlers
);
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
msw: {
handlers,
},
};
const testQueryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const preview: Preview = {
decorators: (Story) => (
export const decorators = [
(Story) => (
<QueryClientProvider client={testQueryClient}>
<UIRouter plugins={[pushStateLocationPlugin]}>
<Story />
</UIRouter>
</QueryClientProvider>
),
loaders: [mswLoader],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
msw: {
handlers,
},
},
};
];
export default preview;
export const loaders = [mswLoader];
-44
View File
@@ -1,44 +0,0 @@
# Portainer Community Edition
Open-source container management platform with full Docker and Kubernetes support.
see also:
- docs/guidelines/server-architecture.md
- docs/guidelines/go-conventions.md
- docs/guidelines/typescript-conventions.md
## Package Manager
- **PNPM** 10+ (for frontend)
- **Go** 1.25.7 (for backend)
## Build Commands
```bash
# Full build
make build # Build both client and server
make build-client # Build React/AngularJS frontend
make build-server # Build Go binary
make build-image # Build Docker image
# Development
make dev # Run both in dev mode
make dev-client # Start webpack-dev-server (port 8999)
make dev-server # Run containerized Go server
pnpm run dev # Webpack dev server
pnpm run build # Build frontend with webpack
pnpm run test # Run frontend tests
# Testing
make test # All tests (backend + frontend)
make test-server # Backend tests only
make lint # Lint all code
make format # Format code
```
## Development Servers
- Frontend: http://localhost:8999
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
+1 -2
View File
@@ -10,7 +10,6 @@ import (
"path/filepath"
"strings"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/logs"
)
@@ -109,7 +108,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
case tar.TypeDir:
// skip, dir will be created with a file
case tar.TypeReg:
p := filesystem.JoinPaths(outputDirPath, header.Name)
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
}
-56
View File
@@ -1,15 +1,12 @@
package archive
import (
"archive/tar"
"compress/gzip"
"os"
"os/exec"
"path"
"path/filepath"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/rs/zerolog/log"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -111,56 +108,3 @@ func Test_shouldCreateArchive2(t *testing.T) {
wasExtracted("dir/inner")
wasExtracted("dir/.dotfile")
}
func TestExtractTarGzPathTraversal(t *testing.T) {
testDir := t.TempDir()
// Create an evil file with a path traversal attempt
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
evilFile, err := os.Create(tarPath)
require.NoError(t, err)
gzWriter := gzip.NewWriter(evilFile)
tarWriter := tar.NewWriter(gzWriter)
content := []byte("evil content")
header := &tar.Header{
Name: "../evil.txt",
Mode: 0600,
Size: int64(len(content)),
Typeflag: tar.TypeReg,
}
err = tarWriter.WriteHeader(header)
require.NoError(t, err)
_, err = tarWriter.Write(content)
require.NoError(t, err)
err = tarWriter.Close()
require.NoError(t, err)
err = gzWriter.Close()
require.NoError(t, err)
err = evilFile.Close()
require.NoError(t, err)
// Attempt to extract the evil file
extractionDir := filesystem.JoinPaths(testDir, "extraction")
err = os.Mkdir(extractionDir, 0700)
require.NoError(t, err)
tarFile, err := os.Open(tarPath)
require.NoError(t, err)
// Check that the file didn't escape
err = ExtractTarGz(tarFile, extractionDir)
require.NoError(t, err)
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
err = tarFile.Close()
require.NoError(t, err)
}
+1 -3
View File
@@ -92,9 +92,7 @@ func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Con
}
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
if !config.TLS && fipsEnabled {
return nil, fips.ErrTLSRequired
} else if !config.TLS {
if !config.TLS {
return nil, nil
}
+2 -2
View File
@@ -45,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
}
}
if err := json.Unmarshal(data, object); err != nil {
if e := json.Unmarshal(data, object); e != nil {
// Special case for the VERSION bucket. Here we're not using json
// So we need to return it as a string
s, ok := object.(*string)
if !ok {
return errors.Wrap(err, "Failed unmarshalling object")
return errors.Wrap(err, e.Error())
}
*s = string(data)
+1 -1
View File
@@ -6,7 +6,7 @@ import (
var (
ErrObjectNotFound = errors.New("object not found inside the database")
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
ErrDBImportFailed = errors.New("importing backup failed")
ErrDatabaseIsUpdating = errors.New("database is currently in updating state. Failed prior upgrade. Please restore from backup or delete the database and restart Portainer")
)
-7
View File
@@ -31,13 +31,6 @@ func NewService(connection portainer.Connection) (*Service, error) {
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
service: service,
tx: tx,
}
}
// Settings retrieve the ssl settings object.
func (service *Service) Settings() (*portainer.SSLSettings, error) {
var settings portainer.SSLSettings
-31
View File
@@ -1,31 +0,0 @@
package ssl
import (
portainer "github.com/portainer/portainer/api"
)
type ServiceTx struct {
service *Service
tx portainer.Transaction
}
func (service ServiceTx) BucketName() string {
return BucketName
}
// Settings retrieve the settings object.
func (service ServiceTx) Settings() (*portainer.SSLSettings, error) {
var settings portainer.SSLSettings
err := service.tx.GetObject(BucketName, []byte(key), &settings)
if err != nil {
return nil, err
}
return &settings, nil
}
// UpdateSettings persists a Settings object.
func (service ServiceTx) UpdateSettings(settings *portainer.SSLSettings) error {
return service.tx.UpdateObject(BucketName, []byte(key), settings)
}
+1 -1
View File
@@ -95,7 +95,7 @@ func (m *Migrator) NeedsMigration() bool {
// In this particular instance we should log a fatal error
if m.CurrentDBEdition() != portainer.PortainerCE {
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
return false
}
+1 -3
View File
@@ -74,9 +74,7 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
return tx.store.SnapshotService.Tx(tx.tx)
}
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService {
return tx.store.SSLSettingsService.Tx(tx.tx)
}
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
func (tx *StoreTx) Stack() dataservices.StackService {
return tx.store.StackService.Tx(tx.tx)
@@ -613,7 +613,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.39.0",
"KubectlShellImage": "portainer/kubectl-shell:2.38.1",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -942,7 +942,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.39.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.38.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+1 -14
View File
@@ -6,7 +6,6 @@ import (
"strings"
"sync"
"github.com/containerd/containerd/errdefs"
"github.com/docker/docker/api/types/container"
)
@@ -36,10 +35,8 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
var aggErr error
var aggMu sync.Mutex
var processedCount int
for i := range containers {
id := containers[i].ID
semaphore <- struct{}{}
wg.Go(func() {
defer func() { <-semaphore }()
@@ -47,17 +44,8 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
containerInspection, err := cli.ContainerInspect(ctx, id)
stat := ContainerStats{}
if err != nil {
if errdefs.IsNotFound(err) {
// An edge case is reported that Docker can list containers with no names,
// but when inspecting a container with specific ID and it is not found.
// In this case, we can safely ignore the error.
// ref@https://linear.app/portainer/issue/BE-12567/500-error-when-loading-docker-dashboard-in-portainer
return
}
aggMu.Lock()
aggErr = errors.Join(aggErr, err)
processedCount++
aggMu.Unlock()
return
}
@@ -68,7 +56,6 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
stopped += stat.Stopped
healthy += stat.Healthy
unhealthy += stat.Unhealthy
processedCount++
mu.Unlock()
})
}
@@ -80,7 +67,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
Stopped: stopped,
Healthy: healthy,
Unhealthy: unhealthy,
Total: processedCount,
Total: len(containers),
}, aggErr
}
+13 -11
View File
@@ -3,11 +3,9 @@ package stats
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/containerd/containerd/errdefs"
"github.com/docker/docker/api/types/container"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -39,7 +37,6 @@ func TestCalculateContainerStats(t *testing.T) {
{ID: "container8"},
{ID: "container9"},
{ID: "container10"},
{ID: "container11"},
}
// Setup mock expectations with different container states to test various scenarios
@@ -61,6 +58,7 @@ func TestCalculateContainerStats(t *testing.T) {
{"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
}
expected := ContainerStats{}
// Setup mock expectations for all containers with artificial delays to simulate real Docker calls
for _, state := range containerStates {
mockClient.On("ContainerInspect", mock.Anything, state.id).Return(container.InspectResponse{
@@ -70,11 +68,14 @@ func TestCalculateContainerStats(t *testing.T) {
Health: state.health,
},
},
}, nil).After(30 * time.Millisecond) // Simulate 30ms Docker API call
}
}, nil).After(50 * time.Millisecond) // Simulate 50ms Docker API call
// Setup mock expectation for a container that returns NotFound error
mockClient.On("ContainerInspect", mock.Anything, "container11").Return(container.InspectResponse{}, fmt.Errorf("No such container: %w", errdefs.ErrNotFound)).After(50 * time.Millisecond)
expected.Running += state.expected.Running
expected.Stopped += state.expected.Stopped
expected.Healthy += state.expected.Healthy
expected.Unhealthy += state.expected.Unhealthy
expected.Total++
}
// Call the function and measure time
startTime := time.Now()
@@ -83,10 +84,11 @@ func TestCalculateContainerStats(t *testing.T) {
duration := time.Since(startTime)
// Assert results
assert.Equal(t, 6, stats.Running)
assert.Equal(t, 4, stats.Stopped)
assert.Equal(t, 2, stats.Healthy)
assert.Equal(t, 2, stats.Unhealthy)
assert.Equal(t, expected, stats)
assert.Equal(t, expected.Running, stats.Running)
assert.Equal(t, expected.Stopped, stats.Stopped)
assert.Equal(t, expected.Healthy, stats.Healthy)
assert.Equal(t, expected.Unhealthy, stats.Unhealthy)
assert.Equal(t, 10, stats.Total)
// Verify concurrent processing by checking that all mock calls were made
-3
View File
@@ -77,9 +77,6 @@ type (
// CreatedByUserId is the user ID that created this stack
// Used for adding labels to Kubernetes manifests
CreatedByUserId string
// HelmConfig represents the Helm configuration for an edge stack
HelmConfig portainer.HelmConfig
}
DeployerOptionsPayload struct {
+1 -1
View File
@@ -112,7 +112,7 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
operations := map[string]func(context.Context, []string) (string, error){
"apply": client.ApplyDynamic,
"delete": client.DeleteDynamic,
"delete": client.Delete,
}
operationFunc, ok := operations[operation]
+3 -4
View File
@@ -1,7 +1,6 @@
package auth
import (
"context"
"errors"
"net/http"
@@ -26,7 +25,7 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
return nil
}
func (handler *Handler) authenticateOAuth(ctx context.Context, code string, settings *portainer.OAuthSettings) (string, error) {
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) {
if code == "" {
return "", errors.New("Invalid OAuth authorization code")
}
@@ -35,7 +34,7 @@ func (handler *Handler) authenticateOAuth(ctx context.Context, code string, sett
return "", errors.New("Invalid OAuth configuration")
}
username, err := handler.OAuthService.Authenticate(ctx, code, settings)
username, err := handler.OAuthService.Authenticate(code, settings)
if err != nil {
return "", err
}
@@ -71,7 +70,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
return httperror.Forbidden("OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled"))
}
username, err := handler.authenticateOAuth(r.Context(), payload.Code, &settings.OAuthSettings)
username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
if err != nil {
log.Debug().Err(err).Msg("OAuth authentication error")
@@ -7,7 +7,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -39,9 +38,9 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request)
}
func deleteEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) error {
ok, err := tx.EdgeGroup().Exists(ID)
if !ok {
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
_, err := tx.EdgeGroup().Read(ID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err)
}
+1 -4
View File
@@ -9,7 +9,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -148,9 +147,7 @@ func (handler *Handler) updateEdgeSchedule(tx dataservices.DataStoreTx, edgeJob
if len(payload.EdgeGroups) > 0 {
for _, edgeGroupID := range payload.EdgeGroups {
if ok, err := tx.EdgeGroup().Exists(edgeGroupID); !ok {
return dserrors.ErrObjectNotFound
} else if err != nil {
if _, err := tx.EdgeGroup().Read(edgeGroupID); err != nil {
return err
}
@@ -5,7 +5,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dserrors "github.com/portainer/portainer/api/dataservices/errors"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
@@ -43,9 +42,9 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht
}
func (handler *Handler) removeEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error {
ok, err := tx.EndpointGroup().Exists(endpointGroupID)
if !ok {
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
_, err := tx.EndpointGroup().Read(endpointGroupID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
}
@@ -149,9 +149,11 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
if endpoint.GroupID == endpointGroup.ID && endpointutils.IsKubernetesEndpoint(&endpoint) {
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(tx, &endpoint, endpointGroup); err != nil {
// Update flag with endpoint and continue
if err := handler.PendingActionsService.Create(tx, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, &endpointGroup.ID)); err != nil {
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpoint.ID, endpointGroup.ID)
}
go func(endpointID portainer.EndpointID, endpointGroupID portainer.EndpointGroupID) {
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpointID, &endpointGroupID)); err != nil {
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID)
}
}(endpoint.ID, endpointGroup.ID)
}
}
}
@@ -173,7 +173,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
err = tx.Tag().Update(tagID, tag)
}
if tx.IsErrObjectNotFound(err) {
if handler.DataStore.IsErrObjectNotFound(err) {
log.Warn().Err(err).Msg("Unable to find tag inside the database")
} else if err != nil {
log.Warn().Err(err).Msg("Unable to delete tag relation from the database")
@@ -221,7 +221,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
}
if endpointutils.IsEdgeEndpoint(endpoint) {
edgeJobs, err := tx.EdgeJob().ReadAll()
edgeJobs, err := handler.DataStore.EdgeJob().ReadAll()
if err != nil {
log.Warn().Err(err).Msg("Unable to retrieve edge jobs from the database")
}
@@ -265,7 +265,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(handler.DataStore, endpoint, nil); err != nil {
log.Warn().Err(err).Msgf("Unable to clean NAP with override policies for endpoint (%d). Will try to update when endpoint is online.", endpoint.ID)
if err := handler.PendingActionsService.Create(handler.DataStore, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
log.Warn().Err(err).Msg("unable to schedule pending action to clean NAP with override policies")
}
}
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.39.0
// @version 2.38.1
// @description.markdown api-description.md
// @termsOfService
-1
View File
@@ -177,7 +177,6 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteError(w, http.StatusForbidden, "an error occurred during the KubeClientMiddleware operation, permission denied to access the environment. Error: ", err)
return
}
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
+32 -38
View File
@@ -5,7 +5,6 @@ import (
"net/http"
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/security"
"github.com/portainer/portainer/api/internal/registryutils"
@@ -52,52 +51,47 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *
return httperror.InternalServerError("Unable to remove the registry from the database", err)
}
handler.deleteKubernetesSecrets(handler.DataStore, registry)
handler.deleteKubernetesSecrets(registry)
return response.Empty(w)
}
func (handler *Handler) deleteKubernetesSecrets(tx dataservices.DataStoreTx, registry *portainer.Registry) {
func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) {
for endpointId, access := range registry.RegistryAccesses {
if access.Namespaces == nil {
continue
}
if access.Namespaces != nil {
// Obtain a kubeclient for the endpoint
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointId)
if err != nil {
// Skip environments that can't be loaded from the DB
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
// Obtain a kubeclient for the endpoint
endpoint, err := tx.Endpoint().Endpoint(endpointId)
if err != nil {
// Skip environments that can't be loaded from the DB
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
continue
}
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
// Skip environments that can't get a kubeclient from
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
continue
}
failedNamespaces := make([]string, 0)
for _, ns := range access.Namespaces {
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
failedNamespaces = append(failedNamespaces, ns)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
continue
}
}
if len(failedNamespaces) == 0 {
continue
}
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
// Skip environments that can't get a kubeclient from
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
if err := handler.PendingActionsService.Create(
tx,
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
); err != nil {
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
continue
}
failedNamespaces := make([]string, 0)
for _, ns := range access.Namespaces {
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
failedNamespaces = append(failedNamespaces, ns)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
}
}
if len(failedNamespaces) > 0 {
if err := handler.PendingActionsService.Create(
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
); err != nil {
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
}
}
}
}
}
@@ -269,7 +269,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
//make sure the webhook ID is unique
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook)
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
if err != nil {
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
}
@@ -214,7 +214,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
// Make sure the webhook ID is unique
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); err != nil {
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
} else if !isUnique {
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
+10 -5
View File
@@ -192,23 +192,28 @@ func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference
// @router /stacks/create/swarm/repository [post]
func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
var payload swarmStackFromGitRepositoryPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
if isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true); err != nil {
isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
if err != nil {
return httperror.InternalServerError("Unable to check for name collision", err)
} else if !isUnique {
}
if !isUnique {
return stackExistsError(payload.Name)
}
//make sure the webhook ID is unique
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
if err != nil {
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
} else if !isUnique {
}
if !isUnique {
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
}
}
+3 -3
View File
@@ -206,9 +206,9 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
return isUniqueStackName, nil
}
func (handler *Handler) checkUniqueWebhookID(tx dataservices.DataStoreTx, webhookID string) (bool, error) {
_, err := tx.Stack().StackByWebhookID(webhookID)
if tx.IsErrObjectNotFound(err) {
func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) {
_, err := handler.DataStore.Stack().StackByWebhookID(webhookID)
if handler.DataStore.IsErrObjectNotFound(err) {
return true, nil
}
return false, err
+1 -1
View File
@@ -76,7 +76,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
(stack.AutoUpdate == nil ||
(stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) {
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); !isUnique || err != nil {
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); !isUnique || err != nil {
return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists"))
}
}
+1 -1
View File
@@ -747,7 +747,7 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht
responseObject = decorateObject(responseObject, resourceControl)
return utils.RewriteResponse(response, responseObject, response.StatusCode)
return utils.RewriteResponse(response, responseObject, http.StatusOK)
}
func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
+10 -26
View File
@@ -3,10 +3,8 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"github.com/rs/zerolog/log"
v1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
@@ -57,43 +55,29 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
TTY: true,
}, scheme.ParameterCodec)
streamOpts := remotecommand.StreamOptions{
Stdin: stdin,
Stdout: stdout,
Tty: true,
}
// Try WebSocket executor first, fall back to SPDY if it fails
exec, err := remotecommand.NewWebSocketExecutorForProtocols(
config,
"GET", // WebSocket uses GET for the upgrade request
req.URL().String(),
channelProtocolList...,
)
if err == nil {
err = exec.StreamWithContext(context.TODO(), streamOpts)
if err == nil {
if err != nil {
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
errChan <- err
return
}
log.Warn().
Err(err).
Str("context", "StartExecProcess").
Msg("WebSocket exec failed, falling back to SPDY")
}
// Fall back to SPDY executor
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
errChan <- fmt.Errorf("unable to create SPDY executor: %w", err)
return
}
err = exec.StreamWithContext(context.TODO(), streamOpts)
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{
Stdin: stdin,
Stdout: stdout,
Tty: true,
})
if err != nil {
var exitError utilexec.ExitError
if !errors.As(err, &exitError) {
errChan <- fmt.Errorf("unable to start exec process: %w", err)
errChan <- errors.New("unable to start exec process")
}
}
}
+14 -11
View File
@@ -30,11 +30,8 @@ func NewService() Service {
// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token environment(endpoint).
// On success, it will then return the username and token expiry time associated to authenticated user by fetching this information
// from the resource server and matching it with the user identifier setting.
func (Service) Authenticate(ctx context.Context, code string, configuration *portainer.OAuthSettings) (string, error) {
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
token, err := GetOAuthToken(ctx, code, configuration)
func (Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
token, err := GetOAuthToken(code, configuration)
if err != nil {
log.Error().Err(err).Msg("failed retrieving oauth token")
@@ -46,7 +43,7 @@ func (Service) Authenticate(ctx context.Context, code string, configuration *por
log.Error().Err(err).Msg("failed parsing id_token")
}
resource, err := GetResource(ctx, token.AccessToken, configuration.ResourceURI)
resource, err := GetResource(token.AccessToken, configuration.ResourceURI)
if err != nil {
log.Error().Err(err).Msg("failed retrieving resource")
@@ -65,7 +62,7 @@ func (Service) Authenticate(ctx context.Context, code string, configuration *por
return username, nil
}
func GetOAuthToken(ctx context.Context, code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
unescapedCode, err := url.QueryUnescape(code)
if err != nil {
return nil, err
@@ -73,6 +70,9 @@ func GetOAuthToken(ctx context.Context, code string, configuration *portainer.OA
config := buildConfig(configuration)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
return config.Exchange(ctx, unescapedCode)
}
@@ -87,7 +87,9 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
return tokenData, nil
}
jwtParser := jwt.Parser{SkipClaimsValidation: true}
jwtParser := jwt.Parser{
SkipClaimsValidation: true,
}
t, _, err := jwtParser.ParseUnverified(idToken.(string), jwt.MapClaims{})
if err != nil {
@@ -101,15 +103,16 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
return tokenData, nil
}
func GetResource(ctx context.Context, token string, resourceURI string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURI, nil)
func GetResource(token string, resourceURI string) (map[string]any, error) {
req, err := http.NewRequest(http.MethodGet, resourceURI, nil)
if err != nil {
return nil, err
}
client := &http.Client{}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
+7 -7
View File
@@ -18,14 +18,14 @@ func Test_getOAuthToken(t *testing.T) {
t.Run("getOAuthToken fails upon invalid code", func(t *testing.T) {
code := ""
if _, err := GetOAuthToken(t.Context(), code, config); err == nil {
if _, err := GetOAuthToken(code, config); err == nil {
t.Errorf("getOAuthToken should fail upon providing invalid code; code=%v", code)
}
})
t.Run("getOAuthToken succeeds upon providing valid code", func(t *testing.T) {
code := validCode
token, err := GetOAuthToken(t.Context(), code, config)
token, err := GetOAuthToken(code, config)
if token == nil || err != nil {
t.Errorf("getOAuthToken should successfully return access token upon providing valid code")
@@ -92,19 +92,19 @@ func Test_getResource(t *testing.T) {
defer srv.Close()
t.Run("should fail upon missing Authorization Bearer header", func(t *testing.T) {
if _, err := GetResource(t.Context(), "", config.ResourceURI); err == nil {
if _, err := GetResource("", config.ResourceURI); err == nil {
t.Errorf("getResource should fail if access token is not provided in auth bearer header")
}
})
t.Run("should fail upon providing incorrect Authorization Bearer header", func(t *testing.T) {
if _, err := GetResource(t.Context(), "incorrect-token", config.ResourceURI); err == nil {
if _, err := GetResource("incorrect-token", config.ResourceURI); err == nil {
t.Errorf("getResource should fail if incorrect access token provided in auth bearer header")
}
})
t.Run("should succeed upon providing correct Authorization Bearer header", func(t *testing.T) {
if _, err := GetResource(t.Context(), oauthtest.AccessToken, config.ResourceURI); err != nil {
if _, err := GetResource(oauthtest.AccessToken, config.ResourceURI); err != nil {
t.Errorf("getResource should succeed if correct access token provided in auth bearer header")
}
})
@@ -118,7 +118,7 @@ func Test_Authenticate(t *testing.T) {
srv, config := oauthtest.RunOAuthServer(code, &portainer.OAuthSettings{})
defer srv.Close()
if _, err := authService.Authenticate(t.Context(), code, config); err == nil {
if _, err := authService.Authenticate(code, config); err == nil {
t.Error("Authenticate should fail to extract username from resource if incorrect UserIdentifier provided")
}
})
@@ -128,7 +128,7 @@ func Test_Authenticate(t *testing.T) {
srv, config := oauthtest.RunOAuthServer(code, config)
defer srv.Close()
username, err := authService.Authenticate(t.Context(), code, config)
username, err := authService.Authenticate(code, config)
if err != nil {
t.Errorf("Authenticate should succeed to extract username from resource if correct UserIdentifier provided; UserIdentifier=%s", config.UserIdentifier)
}
+45 -51
View File
@@ -9,7 +9,6 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/endpointutils"
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
)
@@ -21,34 +20,38 @@ type PendingActionsService struct {
var handlers = make(map[string]portainer.PendingActionHandler)
func NewService(dataStore dataservices.DataStore, kubeFactory *kubecli.ClientFactory) *PendingActionsService {
return &PendingActionsService{dataStore: dataStore, kubeFactory: kubeFactory}
func NewService(
dataStore dataservices.DataStore,
kubeFactory *kubecli.ClientFactory,
) *PendingActionsService {
return &PendingActionsService{
dataStore: dataStore,
kubeFactory: kubeFactory,
mu: sync.Mutex{},
}
}
func (service *PendingActionsService) RegisterHandler(name string, handler portainer.PendingActionHandler) {
handlers[name] = handler
}
func (service *PendingActionsService) Create(tx dataservices.DataStoreTx, action portainer.PendingAction) error {
func (service *PendingActionsService) Create(action portainer.PendingAction) error {
// Check if this pendingAction already exists
pendingActions, err := tx.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
return a.EndpointID == action.EndpointID && a.Action == action.Action && reflect.DeepEqual(a.ActionData, action.ActionData)
})
pendingActions, err := service.dataStore.PendingActions().ReadAll()
if err != nil {
return fmt.Errorf("failed to retrieve pending actions: %w", err)
}
if len(pendingActions) > 0 {
for _, dba := range pendingActions {
// Same endpoint, same action and data, don't create a repeat
log.Debug().
Str("action", action.Action).
Int("endpoint_id", int(action.EndpointID)).
Msg("pending action already exists for environment, skipping...")
return nil
if dba.EndpointID == action.EndpointID && dba.Action == action.Action &&
reflect.DeepEqual(dba.ActionData, action.ActionData) {
log.Debug().Msgf("pending action %s already exists for environment %d, skipping...", action.Action, action.EndpointID)
return nil
}
}
return tx.PendingActions().Create(&action)
return service.dataStore.PendingActions().Create(&action)
}
func (service *PendingActionsService) Execute(id portainer.EndpointID) {
@@ -62,8 +65,7 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
endpoint, err := service.dataStore.Endpoint().Endpoint(environmentID)
if err != nil {
log.Debug().Err(err).Int("endpoint_id", int(environmentID)).Msg("failed to retrieve environment")
log.Debug().Msgf("failed to retrieve environment %d: %v", environmentID, err)
return
}
@@ -84,55 +86,48 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
// creating a kube client and performing a simple operation
client, err := service.kubeFactory.GetPrivilegedKubeClient(endpoint)
if err != nil {
log.Debug().
Err(err).
Int("endpoint_id", int(environmentID)).
Msg("failed to create Kubernetes client for environment")
log.Debug().Msgf("failed to create Kubernetes client for environment %d: %v", environmentID, err)
return
}
if _, err = client.ServerVersion(); err != nil {
log.Debug().
Err(err).
Str("endpoint_name", endpoint.Name).
Int("endpoint_id", int(environmentID)).
Msg("environment is not up")
log.Debug().Err(err).Msgf("Environment %q (id: %d) is not up", endpoint.Name, environmentID)
return
}
}
pendingActions, err := service.dataStore.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
return a.EndpointID == environmentID
})
pendingActions, err := service.dataStore.PendingActions().ReadAll()
if err != nil {
log.Warn().Err(err).Msg("failed to read pending actions")
log.Warn().Msgf("failed to read pending actions: %v", err)
return
}
if len(pendingActions) > 0 {
log.Debug().Int("pending_action_count", len(pendingActions)).Msg("found pending actions")
log.Debug().Msgf("Found %d pending actions", len(pendingActions))
}
for _, pendingAction := range pendingActions {
log.Debug().
Int("pending_action_id", int(pendingAction.ID)).
Str("action", pendingAction.Action).
Msg("executing pending action")
if err := service.executePendingAction(pendingAction, endpoint); err != nil {
log.Warn().Err(err).Msg("failed to execute pending action")
for i, pendingAction := range pendingActions {
if pendingAction.EndpointID == environmentID {
if i == 0 {
// We have at least 1 pending action for this environment
log.Debug().Msgf("Executing pending actions for environment %d", environmentID)
}
continue
log.Debug().Msgf("executing pending action id=%d, action=%s", pendingAction.ID, pendingAction.Action)
err := service.executePendingAction(pendingAction, endpoint)
if err != nil {
log.Warn().Msgf("failed to execute pending action: %v", err)
continue
}
err = service.dataStore.PendingActions().Delete(pendingAction.ID)
if err != nil {
log.Warn().Msgf("failed to delete pending action: %v", err)
continue
}
log.Debug().Msgf("pending action %d finished", pendingAction.ID)
}
if err := service.dataStore.PendingActions().Delete(pendingAction.ID); err != nil {
log.Warn().Err(err).Msg("failed to delete pending action")
continue
}
log.Debug().Int("pending_action_id", int(pendingAction.ID)).Msg("pending action finished")
}
}
@@ -145,8 +140,7 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
handler, ok := handlers[pendingAction.Action]
if !ok {
log.Warn().Str("action", pendingAction.Action).Msg("no handler found for pending action")
log.Warn().Msgf("no handler found for pending action %s", pendingAction.Action)
return nil
}
+9 -26
View File
@@ -355,20 +355,6 @@ type (
CreatedBy string `example:"admin"`
}
// HelmConfig represents the Helm configuration for an edge stack
HelmConfig struct {
// Path to a Helm chart folder for Helm git deployments
ChartPath string `json:"ChartPath,omitempty" example:"charts/my-app"`
// Array of paths to Helm values YAML files for Helm git deployments
ValuesFiles []string `json:"ValuesFiles,omitempty" example:"['values/prod.yaml', 'values/secrets.yaml']"`
// Helm chart version from Chart.yaml (read-only, extracted during Git sync)
Version string `json:"Version,omitempty" example:"1.2.3"`
// Enable automatic rollback on deployment failure (equivalent to helm --atomic flag)
Atomic bool `json:"Atomic" example:"true"`
// Timeout for Helm operations (equivalent to helm --timeout flag)
Timeout string `json:"Timeout,omitempty" example:"5m0s"`
}
EdgeStackStatusForEnv struct {
EndpointID EndpointID
Status []EdgeStackDeploymentStatus
@@ -558,14 +544,11 @@ type (
}
PolicyChartStatus struct {
// EnvironmentID is the endpoint this status belongs to.
// Stored so that ReadAll can group statuses by endpoint without parsing keys.
EnvironmentID EndpointID `json:"environmentID,omitempty"`
ChartName string `json:"chartName"`
Fingerprint string `json:"fingerprint"`
Status HelmInstallStatus `json:"status"`
Message string `json:"message"`
Namespace string `json:"namespace"`
ChartName string `json:"chartName"`
Fingerprint string `json:"fingerprint"`
Status HelmInstallStatus `json:"status"`
Message string `json:"message"`
Namespace string `json:"namespace"`
// Unix timestamp
LastAttemptTime int64 `json:"lastAttemptTime"`
}
@@ -576,7 +559,7 @@ type (
}
PolicyChartBundle struct {
PolicyChartSummary `mapstructure:",squash"`
PolicyChartSummary
EncodedTgz string `json:"EncodedTgz"`
Namespace string `json:"Namespace"`
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
@@ -1834,7 +1817,7 @@ type (
// OAuthService represents a service used to authenticate users using OAuth
OAuthService interface {
Authenticate(ctx context.Context, code string, configuration *OAuthSettings) (string, error)
Authenticate(code string, configuration *OAuthSettings) (string, error)
}
// ReverseTunnelService represents a service used to manage reverse tunnel connections.
@@ -1874,9 +1857,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.39.0"
APIVersion = "2.38.1"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
+10 -2
View File
@@ -74,10 +74,18 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
}
}
return d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
if err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
ComposeOptions: options,
ForceRecreate: forceRecreate,
})
}); err != nil {
if err := d.composeStackManager.Down(context.TODO(), stack, endpoint); err != nil {
log.Warn().Err(err).Msg("failed to cleanup compose stack after failed deployment")
}
return err
}
return nil
}
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
+2 -2
View File
@@ -88,7 +88,7 @@ div.input-mask {
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
background: var(--bg-widget-color);
border: 1px solid var(--border-widget);
border-radius: 12px;
border-radius: 8px;
}
.widget .widget-header .pagination,
.widget .widget-footer .pagination {
@@ -103,7 +103,7 @@ div.input-mask {
.widget .widget-body {
padding: 20px;
border-radius: 12px;
border-radius: 8px;
}
.widget .widget-body table thead {
background: var(--bg-widget-table-color);
+7 -4
View File
@@ -61,8 +61,11 @@ angular
.config(configApp);
if (require) {
const req = require.context('./', true, /^(?!.*\.test\.js$).*\.js$/im);
req.keys().forEach(function (key) {
req(key);
});
const req = require.context('./', true, /^(.*\.(js$))[^.]*$/im);
req
.keys()
.filter((path) => !path.includes('.test'))
.forEach(function (key) {
req(key);
});
}
+2 -1
View File
@@ -195,7 +195,8 @@ angular
},
views: {
'content@': {
component: 'environmentsItemView',
templateUrl: './views/endpoints/edit/endpoint.html',
controller: 'EndpointController',
},
},
};
+65 -1
View File
@@ -1,10 +1,74 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { EdgeKeyDisplay } from '@/react/portainer/environments/ItemView/EdgeKeyDisplay';
import { EdgeAgentDeploymentWidget } from '@/react/portainer/environments/ItemView/EdgeAgentDeploymentWidget/EdgeAgentDeploymentWidget';
import { KVMControl } from '@/react/portainer/environments/KvmView/KVMControl';
import { TagsDatatable } from '@/react/portainer/environments/TagsView/TagsDatatable';
import { EnvironmentBasicConfigSection } from '@/react/portainer/environments/ItemView/EnvironmentBasicConfigSection/EnvironmentBasicConfigSection';
import { EdgeInformationPanel } from '@/react/portainer/environments/ItemView/EdgeInformationPanel/EdgeInformationPanel';
import { KubeConfigInfo } from '@/react/portainer/environments/ItemView/KubeConfigInfo/KubeConfigInfo';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AzureEnvironmentForm } from '@/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm';
import { GeneralEnvironmentForm } from '@/react/portainer/environments/ItemView/GeneralEnvironmentForm/GeneralEnvironmentForm';
export const environmentsModule = angular
.module('portainer.app.react.components.environments', [])
.component('edgeKeyDisplay', r2a(EdgeKeyDisplay, ['edgeKey']))
.component(
'edgeAgentDeploymentWidget',
r2a(withCurrentUser(withUIRouter(EdgeAgentDeploymentWidget)), [
'edgeKey',
'edgeId',
'asyncMode',
])
)
.component(
'kubeConfigInfo',
r2a(withUIRouter(withReactQuery(KubeConfigInfo)), [
'environmentId',
'environmentType',
'edgeId',
'status',
])
)
.component('kvmControl', r2a(KVMControl, ['deviceId', 'server', 'token']))
.component('tagsDatatable', r2a(TagsDatatable, ['dataset', 'onRemove'])).name;
.component('tagsDatatable', r2a(TagsDatatable, ['dataset', 'onRemove']))
.component(
'environmentBasicConfigSection',
r2a(EnvironmentBasicConfigSection, [
'values',
'setValues',
'isEdge',
'isAzure',
'isAgent',
'hasError',
'isLocalEnvironment',
])
)
.component(
'edgeInformationPanel',
r2a(withUIRouter(withReactQuery(EdgeInformationPanel)), [
'environmentId',
'edgeKey',
'edgeId',
'platformName',
'onSuccess',
])
)
.component(
'azureEnvironmentForm',
r2a(withUIRouter(withReactQuery(withCurrentUser(AzureEnvironmentForm))), [
'environment',
'onSuccess',
])
)
.component(
'generalEnvironmentForm',
r2a(withUIRouter(withReactQuery(withCurrentUser(GeneralEnvironmentForm))), [
'environment',
'onSuccess',
])
).name;
-23
View File
@@ -1,23 +0,0 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/portainer/environments/ListView';
import { EdgeAutoCreateScriptViewWrapper } from '@/react/portainer/environments/EdgeAutoCreateScriptView/EdgeAutoCreateScriptView';
import { ItemView } from '@/react/portainer/environments/ItemView/ItemView';
export const environmentsModule = angular
.module('portainer.app.react.views.environments', [])
.component(
'environmentsListView',
r2a(withUIRouter(withCurrentUser(ListView)), [])
)
.component(
'environmentsItemView',
r2a(withUIRouter(withCurrentUser(ItemView)), [])
)
.component(
'edgeAutoCreateScriptView',
r2a(withUIRouter(withCurrentUser(EdgeAutoCreateScriptViewWrapper)), [])
).name;
+13 -3
View File
@@ -7,6 +7,8 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { CreateUserAccessToken } from '@/react/portainer/account/CreateAccessTokenView';
import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeView/EdgeComputeSettingsView';
import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView';
import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView';
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView';
import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repositories/CreateHelmRepositoryView';
@@ -19,7 +21,6 @@ import { registriesModule } from './registries';
import { activityLogsModule } from './activity-logs';
import { templatesModule } from './templates';
import { usersModule } from './users';
import { environmentsModule } from './environments';
export const viewsModule = angular
.module('portainer.app.react.views', [
@@ -31,12 +32,18 @@ export const viewsModule = angular
activityLogsModule,
templatesModule,
usersModule,
environmentsModule,
])
.component(
'homeView',
r2a(withUIRouter(withReactQuery(withCurrentUser(HomeView))), [])
)
.component(
'edgeAutoCreateScriptView',
r2a(
withUIRouter(withReactQuery(withCurrentUser(EdgeAutoCreateScriptView))),
[]
)
)
.component(
'createUserAccessToken',
r2a(
@@ -51,7 +58,10 @@ export const viewsModule = angular
['onSubmit', 'settings']
)
)
.component(
'environmentsListView',
r2a(withUIRouter(withReactQuery(withCurrentUser(EnvironmentsListView))), [])
)
.component(
'backupSettingsPanel',
r2a(withUIRouter(withReactQuery(withCurrentUser(BackupSettingsPanel))), [])
@@ -0,0 +1,181 @@
<page-header ng-if="endpoint" title="'Environment details'" breadcrumbs="[{label:'Environments', link:'portainer.endpoints'}, endpoint.Name]" reload="true"> </page-header>
<div>
<div class="mx-4 space-y-4 [&>*]:block mb-4" ng-if="state.edgeEndpoint">
<edge-information-panel
ng-if="state.edgeAssociated"
environment-id="endpoint.Id"
edge-key="endpoint.EdgeKey"
edge-id="endpoint.EdgeID"
platform-name="state.platformName"
on-success="(onDisassociateSuccess)"
>
</edge-information-panel>
<div ng-if="!state.edgeAssociated">
<edge-agent-deployment-widget edge-key="endpoint.EdgeKey" edge-id="endpoint.EdgeID" async-mode="endpoint.Edge.AsyncMode"></edge-agent-deployment-widget>
</div>
</div>
<div class="mx-4 space-y-4 [&>*]:block mb-4">
<kube-config-info environment-id="endpoint.Id" environment-type="endpoint.Type" edge-id="endpoint.EdgeID" status="endpoint.Status"></kube-config-info>
</div>
</div>
<div ng-if="state.azureEndpoint" class="col-sm-12">
<azure-environment-form environment="endpoint" on-cancel="(cancelUpdateEndpoint)" on-success="(onUpdateSuccess)"></azure-environment-form>
</div>
<div class="row mt-4" ng-if="!state.azureEndpoint && !state.edgeEndpoint">
<div class="col-lg-12 col-md-12 col-xs-12" ng-if="endpoint">
<general-environment-form environment="endpoint" on-cancel="(cancelUpdateEndpoint)" on-success="(onUpdateSuccess)"></general-environment-form>
</div>
</div>
<div class="row mt-4" ng-if="state.edgeEndpoint">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="$ctrl.endpointForm">
<environment-basic-config-section
values="basicConfigValues"
set-values="updateBasicConfig"
is-edge="state.edgeEndpoint"
is-azure="state.azureEndpoint"
is-agent="state.agentEndpoint"
has-error="endpoint.Status === 4"
is-local-environment="endpointType === 'local'"
></environment-basic-config-section>
<div ng-if="endpoint && state.edgeEndpoint">
<div class="col-sm-12 form-section-title"> Check-in Intervals </div>
<edge-checkin-interval-field value="endpoint.EdgeCheckinInterval" on-change="(onChangeCheckInInterval)"></edge-checkin-interval-field>
</div>
<!-- !endpoint-public-url-input -->
<tls-fieldset
ng-if="!state.edgeEndpoint && endpoint.Status !== 4 && state.showTLSConfig"
values="formValues.tlsConfig"
on-change="(onChangeTLSConfigFormValues)"
validation-data="{optionalCert: true}"
></tls-fieldset>
<div class="col-sm-12 form-section-title"> Metadata </div>
<!-- group -->
<div class="form-group">
<label for="endpoint_group" class="col-sm-3 col-lg-2 control-label text-left"> Group </label>
<div class="col-sm-9 col-lg-10">
<select
ng-options="group.Id as group.Name for group in groups"
ng-model="endpoint.GroupId"
id="endpoint_group"
class="form-control"
data-cy="endpoint-group-select"
></select>
</div>
</div>
<!-- !group -->
<tag-selector ng-if="endpoint" value="endpoint.TagIds" allow-create="state.allowCreate" on-change="(onChangeTags)"></tag-selector>
<!-- open-amt info -->
<div ng-if="state.showAMTInfo">
<div class="col-sm-12 form-section-title"> Open Active Management Technology </div>
<div class="form-group">
<label for="endpoint_managementinfoVersion" class="col-sm-3 col-lg-2 control-label text-left"> AMT Version </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoVersion"
ng-model="endpoint.ManagementInfo['AMT']"
placeholder="Loading..."
data-cy="endpoint-managementinfoVersion"
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoUUID" class="col-sm-3 col-lg-2 control-label text-left"> UUID </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoUUID"
ng-model="endpoint.ManagementInfo['UUID']"
placeholder="Loading..."
data-cy="endpoint-managementinfoUUID"
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoBuildNumber" class="col-sm-3 col-lg-2 control-label text-left"> Build Number </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="endpoint-managementinfoBuildNumber"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoBuildNumber"
ng-model="endpoint.ManagementInfo['Build Number']"
placeholder="Loading..."
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoControlMode" class="col-sm-3 col-lg-2 control-label text-left"> Control Mode </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="endpoint-managementinfoControlMode"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoControlMode"
ng-model="endpoint.ManagementInfo['Control Mode']"
placeholder="Loading..."
/>
</div>
</div>
<div class="form-group">
<label for="endpoint_managementinfoDNSSuffix" class="col-sm-3 col-lg-2 control-label text-left"> DNS Suffix </label>
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="endpoint-managementinfoDNSSuffix"
ng-disabled="true"
class="form-control"
id="endpoint_managementinfoDNSSuffix"
ng-model="endpoint.ManagementInfo['DNS Suffix']"
placeholder="Loading..."
/>
</div>
</div>
</div>
<!-- !open-amt info -->
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="state.actionInProgress || !endpoint.Name || !endpoint.URL || !$ctrl.endpointForm.$valid"
ng-click="updateEndpoint()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Update environment</span>
<span ng-show="state.actionInProgress">Updating environment...</span>
</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="portainer.endpoints">Cancel</a>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
@@ -0,0 +1,344 @@
import _ from 'lodash-es';
import uuidv4 from 'uuid/v4';
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
import EndpointHelper from '@/portainer/helpers/endpointHelper';
import { getAMTInfo } from 'Portainer/hostmanagement/open-amt/open-amt.service';
import { confirmDestructive } from '@@/modals/confirm';
import { getPlatformTypeName, isEdgeEnvironment, isDockerAPIEnvironment } from '@/react/portainer/environments/utils';
import { buildConfirmButton } from '@@/modals/utils';
import { getInfo } from '@/react/docker/proxy/queries/useInfo';
angular.module('portainer.app').controller('EndpointController', EndpointController);
/* @ngInject */
function EndpointController(
$async,
$scope,
$state,
$transition$,
$filter,
clipboard,
EndpointService,
GroupService,
Notifications,
Authentication,
SettingsService
) {
$scope.onChangeCheckInInterval = onChangeCheckInInterval;
$scope.setFieldValue = setFieldValue;
$scope.onChangeTags = onChangeTags;
$scope.onChangeTLSConfigFormValues = onChangeTLSConfigFormValues;
$scope.onDisassociateSuccess = onDisassociateSuccess;
$scope.state = {
platformName: '',
selectAll: false,
// displayTextFilter: false,
get selectedItemCount() {
return $scope.state.selectedItems.length || 0;
},
selectedItems: [],
uploadInProgress: false,
actionInProgress: false,
azureEndpoint: false,
kubernetesEndpoint: false,
agentEndpoint: false,
edgeEndpoint: false,
edgeAssociated: false,
allowCreate: Authentication.isAdmin(),
allowSelfSignedCerts: true,
showAMTInfo: false,
showTLSConfig: false,
};
$scope.basicConfigValues = {
name: '',
url: '',
publicUrl: '',
};
$scope.selectAll = function () {
$scope.state.firstClickedItem = null;
for (var i = 0; i < $scope.state.filteredDataSet.length; i++) {
var item = $scope.state.filteredDataSet[i];
if (item.Checked !== $scope.state.selectAll) {
// if ($scope.allowSelection(item) && item.Checked !== $scope.state.selectAll) {
item.Checked = $scope.state.selectAll;
$scope.selectItem(item);
}
}
};
function isBetween(value, a, b) {
return (value >= a && value <= b) || (value >= b && value <= a);
}
$scope.selectItem = function (item, event) {
// Handle range select using shift
if (event && event.originalEvent.shiftKey && $scope.state.firstClickedItem) {
const firstItemIndex = $scope.state.filteredDataSet.indexOf($scope.state.firstClickedItem);
const lastItemIndex = $scope.state.filteredDataSet.indexOf(item);
const itemsInRange = _.filter($scope.state.filteredDataSet, (item, index) => {
return isBetween(index, firstItemIndex, lastItemIndex);
});
const value = $scope.state.firstClickedItem.Checked;
_.forEach(itemsInRange, (i) => {
i.Checked = value;
});
$scope.state.firstClickedItem = item;
} else if (event) {
item.Checked = !item.Checked;
$scope.state.firstClickedItem = item;
}
$scope.state.selectedItems = _.uniq(_.concat($scope.state.selectedItems, $scope.state.filteredDataSet)).filter((i) => i.Checked);
if (event && $scope.state.selectAll && $scope.state.selectedItems.length !== $scope.state.filteredDataSet.length) {
$scope.state.selectAll = false;
}
};
$scope.formValues = {
tlsConfig: {
tls: false,
skipVerify: false,
skipClientVerify: false,
caCertFile: null,
certFile: null,
keyFile: null,
},
};
function onDisassociateSuccess() {
$state.reload();
}
function onChangeCheckInInterval(value) {
setFieldValue('EdgeCheckinInterval', value);
}
function onChangeTags(value) {
setFieldValue('TagIds', value);
}
function onChangeTLSConfigFormValues(newValues) {
return this.$async(async () => {
$scope.formValues.tlsConfig = {
...$scope.formValues.tlsConfig,
...newValues,
};
});
}
function setFieldValue(name, value) {
return $scope.$evalAsync(() => {
$scope.endpoint = {
...$scope.endpoint,
[name]: value,
};
});
}
Array.prototype.indexOf = function (val) {
for (var i = 0; i < this.length; i++) {
if (this[i] == val) return i;
}
return -1;
};
Array.prototype.remove = function (val) {
var index = this.indexOf(val);
if (index > -1) {
this.splice(index, 1);
}
};
$scope.updateEndpoint = async function () {
var endpoint = $scope.endpoint;
if (isEdgeEnvironment(endpoint.Type) && _.difference($scope.initialTagIds, endpoint.TagIds).length > 0) {
let confirmed = await confirmDestructive({
title: 'Confirm action',
message: 'Removing tags from this environment will remove the corresponding edge stacks when dynamic grouping is being used',
confirmButton: buildConfirmButton(),
});
if (!confirmed) {
return;
}
}
var payload = {
Name: endpoint.Name,
PublicURL: endpoint.PublicURL,
Gpus: endpoint.Gpus,
GroupID: endpoint.GroupId,
TagIds: endpoint.TagIds,
EdgeCheckinInterval: endpoint.EdgeCheckinInterval,
};
if (
$scope.endpointType !== 'local' &&
endpoint.Type !== PortainerEndpointTypes.AzureEnvironment &&
endpoint.Type !== PortainerEndpointTypes.KubernetesLocalEnvironment &&
endpoint.Type !== PortainerEndpointTypes.AgentOnKubernetesEnvironment
) {
payload.URL = 'tcp://' + endpoint.URL;
if (endpoint.Type === PortainerEndpointTypes.DockerEnvironment) {
var tlsConfig = $scope.formValues.tlsConfig;
payload.TLS = tlsConfig.tls;
payload.TLSSkipVerify = tlsConfig.skipVerify;
if (tlsConfig.tls && !tlsConfig.skipVerify) {
payload.TLSSkipClientVerify = tlsConfig.skipClientVerify;
payload.TLSCACert = tlsConfig.caCertFile;
payload.TLSCert = tlsConfig.certFile;
payload.TLSKey = tlsConfig.keyFile;
}
}
}
if (endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment) {
payload.URL = endpoint.URL;
}
if (endpoint.Type === PortainerEndpointTypes.KubernetesLocalEnvironment) {
payload.URL = 'https://' + endpoint.URL;
}
$scope.state.actionInProgress = true;
EndpointService.updateEndpoint(endpoint.Id, payload).then(
function success() {
onUpdateSuccess();
},
function error(err) {
Notifications.error('Failure', err, 'Unable to update environment');
$scope.state.actionInProgress = false;
},
function update(evt) {
if (evt.upload) {
$scope.state.uploadInProgress = evt.upload;
}
}
);
};
function onUpdateSuccess() {
Notifications.success('Environment updated', $scope.endpoint.Name);
$state.go($state.params.redirectTo || 'portainer.endpoints', {}, { reload: true });
}
function configureState() {
$scope.state.platformName = getPlatformTypeName($scope.endpoint.Type);
if (
$scope.endpoint.Type === PortainerEndpointTypes.KubernetesLocalEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
) {
$scope.state.kubernetesEndpoint = true;
}
if ($scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || $scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
$scope.state.edgeEndpoint = true;
}
if ($scope.endpoint.Type === PortainerEndpointTypes.AzureEnvironment) {
$scope.state.azureEndpoint = true;
}
if (
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnDockerEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment ||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
) {
$scope.state.agentEndpoint = true;
}
}
function configureTLS(endpoint) {
$scope.formValues = {
tlsConfig: {
tls: endpoint.TLSConfig.TLS || false,
skipVerify: endpoint.TLSConfig.TLSSkipVerify || false,
skipClientVerify: endpoint.TLSConfig.TLSSkipClientVerify || false,
},
};
}
async function initView() {
return $async(async () => {
try {
const [endpoint, groups, settings] = await Promise.all([EndpointService.endpoint($transition$.params().id), GroupService.groups(), SettingsService.settings()]);
if (isDockerAPIEnvironment(endpoint)) {
$scope.state.showTLSConfig = true;
}
// Check if the environment is docker standalone, to decide whether to show the GPU insights box
const isDockerEnvironment = endpoint.Type === PortainerEndpointTypes.DockerEnvironment;
if (isDockerEnvironment) {
try {
const dockerInfo = await getInfo(endpoint.Id);
const isDockerSwarmEnv = dockerInfo.Swarm && dockerInfo.Swarm.NodeID;
$scope.isDockerStandaloneEnv = !isDockerSwarmEnv;
} catch (err) {
// $scope.isDockerStandaloneEnv is only used to show the "GPU insights box", so fail quietly on error
}
}
if (endpoint.URL.indexOf('unix://') === 0 || endpoint.URL.indexOf('npipe://') === 0) {
$scope.endpointType = 'local';
} else {
$scope.endpointType = 'remote';
}
if ($scope.state.azureEndpoint || $scope.state.edgeEndpoint) {
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
}
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
$scope.state.edgeAssociated = !!endpoint.EdgeID;
endpoint.EdgeID = endpoint.EdgeID || uuidv4();
}
$scope.endpoint = endpoint;
$scope.initialTagIds = endpoint.TagIds.slice();
$scope.groups = groups;
configureState();
configureTLS(endpoint);
if (EndpointHelper.isDockerEndpoint(endpoint) && $scope.state.edgeAssociated) {
$scope.state.showAMTInfo = settings && settings.openAMTConfiguration && settings.openAMTConfiguration.enabled;
}
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve environment details');
}
if ($scope.state.showAMTInfo) {
try {
$scope.endpoint.ManagementInfo = {};
const amtInfo = await getAMTInfo($state.params.id);
try {
$scope.endpoint.ManagementInfo = JSON.parse(amtInfo.RawOutput);
} catch (err) {
clearAMTManagementInfo(amtInfo.RawOutput);
}
} catch (err) {
clearAMTManagementInfo('Unable to retrieve AMT environment details');
}
}
});
}
function clearAMTManagementInfo(versionValue) {
$scope.endpoint.ManagementInfo['AMT'] = versionValue;
$scope.endpoint.ManagementInfo['UUID'] = '-';
$scope.endpoint.ManagementInfo['Control Mode'] = '-';
$scope.endpoint.ManagementInfo['Build Number'] = '-';
$scope.endpoint.ManagementInfo['DNS Suffix'] = '-';
}
initView();
}
@@ -1,6 +1,6 @@
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { render, screen } from '@testing-library/react';
import { render } from '@testing-library/react';
import { UserViewModel } from '@/portainer/models/user';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
@@ -17,50 +17,31 @@ vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
})),
}));
function renderComponent() {
test('submit button should be disabled when name or image is missing', async () => {
server.use(http.get('/api/endpoints/5', () => HttpResponse.json({})));
const user = new UserViewModel({ Username: 'user' });
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(CreateContainerInstanceForm), user)
);
const { findByText, getByText, getByLabelText } = render(<Wrapped />);
return render(<Wrapped />);
}
await expect(findByText(/Azure settings/)).resolves.toBeVisible();
describe('CreateContainerInstanceForm', () => {
beforeEach(() => {
server.use(http.get('/api/endpoints/5', () => HttpResponse.json({})));
});
const button = getByText(/Deploy the container/);
expect(button).toBeVisible();
expect(button).toBeDisabled();
// TODO: from R8S-730 - enable this test once it passes
it.skip('should not display any visible error messages on initial load', async () => {
renderComponent();
const nameInput = getByLabelText(/name/i, { selector: 'input' });
await userEvent.type(nameInput, 'name');
const errors = await screen.findByRole('alert');
const imageInput = getByLabelText(/image/i, { selector: 'input' });
await userEvent.type(imageInput, 'image');
// Check that no error messages (role="alert") are visible
expect(errors).not.toBeInTheDocument();
});
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
it('submit button should be disabled when name or image is missing', async () => {
const { findByText, getByText, getByLabelText } = renderComponent();
expect(nameInput).toHaveValue('name');
await userEvent.clear(nameInput);
await expect(findByText(/Azure settings/)).resolves.toBeVisible();
const button = getByText(/Deploy the container/);
expect(button).toBeVisible();
expect(button).toBeDisabled();
const nameInput = getByLabelText(/name/i, { selector: 'input' });
await userEvent.type(nameInput, 'name');
const imageInput = getByLabelText(/image/i, { selector: 'input' });
await userEvent.type(imageInput, 'image');
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
expect(nameInput).toHaveValue('name');
await userEvent.clear(nameInput);
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
});
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
});
@@ -1,15 +1,27 @@
import { Formik } from 'formik';
import { Field, Form, Formik } from 'formik';
import { useRouter } from '@uirouter/react';
import { Plus } from 'lucide-react';
import { ContainerInstanceFormValues } from '@/react/azure/types';
import * as notifications from '@/portainer/services/notifications';
import { useCurrentUser } from '@/react/hooks/useUser';
import { AccessControlForm } from '@/react/portainer/access-control/AccessControlForm';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { FormControl } from '@@/form-components/FormControl';
import { Input, Select } from '@@/form-components/Input';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
import { validationSchema } from './CreateContainerInstanceForm.validation';
import { PortsMappingField } from './PortsMappingField';
import { useFormState, useLoadFormState } from './useLoadFormState';
import {
getSubscriptionLocations,
getSubscriptionResourceGroups,
} from './utils';
import { useCreateInstanceMutation } from './useCreateInstanceMutation';
import { CreateContainerInstanceInnerForm } from './CreateContainerInstanceInnerForm';
export function CreateContainerInstanceForm({
defaultValues,
@@ -49,15 +61,161 @@ export function CreateContainerInstanceForm({
validateOnChange
enableReinitialize
>
{(formikProps) => (
<CreateContainerInstanceInnerForm
// eslint-disable-next-line react/jsx-props-no-spreading
{...formikProps}
subscriptionOptions={subscriptionOptions}
environmentId={environmentId}
resourceGroups={resourceGroups}
providers={providers}
/>
{({
errors,
handleSubmit,
isSubmitting,
isValid,
values,
setFieldValue,
}) => (
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
<FormSectionTitle>Azure settings</FormSectionTitle>
<FormControl
label="Subscription"
inputId="subscription-input"
errors={errors.subscription}
>
<Field
name="subscription"
as={Select}
id="subscription-input"
options={subscriptionOptions}
/>
</FormControl>
<FormControl
label="Resource group"
inputId="resourceGroup-input"
errors={errors.resourceGroup}
>
<Field
name="resourceGroup"
as={Select}
id="resourceGroup-input"
options={getSubscriptionResourceGroups(
values.subscription,
resourceGroups
)}
/>
</FormControl>
<FormControl
label="Location"
inputId="location-input"
errors={errors.location}
>
<Field
name="location"
as={Select}
id="location-input"
options={getSubscriptionLocations(values.subscription, providers)}
/>
</FormControl>
<FormSectionTitle>Container configuration</FormSectionTitle>
<FormControl label="Name" inputId="name-input" errors={errors.name}>
<Field
name="name"
as={Input}
id="name-input"
placeholder="e.g. myContainer"
/>
</FormControl>
<FormControl
label="Image"
inputId="image-input"
errors={errors.image}
>
<Field
name="image"
as={Input}
id="image-input"
placeholder="e.g. nginx:alpine"
/>
</FormControl>
<FormControl label="OS" inputId="os-input" errors={errors.os}>
<Field
name="os"
as={Select}
id="os-input"
options={[
{ label: 'Linux', value: 'Linux' },
{ label: 'Windows', value: 'Windows' },
]}
/>
</FormControl>
<PortsMappingField
value={values.ports}
onChange={(value) => setFieldValue('ports', value)}
errors={errors.ports}
/>
<EnvironmentVariablesPanel
values={values.env}
onChange={(env) => setFieldValue('env', env)}
errors={errors.env}
/>
<div className="form-group">
<div className="col-sm-12 small text-muted">
This will automatically deploy a container with a public IP
address
</div>
</div>
<FormSectionTitle>Container Resources</FormSectionTitle>
<FormControl label="CPU" inputId="cpu-input" errors={errors.cpu}>
<Field
name="cpu"
as={Input}
id="cpu-input"
type="number"
placeholder="1"
/>
</FormControl>
<FormControl
label="Memory"
inputId="cpu-input"
errors={errors.memory}
>
<Field
name="memory"
as={Input}
id="memory-input"
type="number"
placeholder="1"
/>
</FormControl>
<AccessControlForm
formNamespace="accessControl"
onChange={(values) => setFieldValue('accessControl', values)}
values={values.accessControl}
errors={errors.accessControl}
environmentId={environmentId}
/>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid}
isLoading={isSubmitting}
loadingText="Deployment in progress..."
icon={Plus}
data-cy="aci-create-button"
>
Deploy the container
</LoadingButton>
</div>
</div>
</Form>
)}
</Formik>
);
@@ -1,182 +0,0 @@
import { Field, Form, FormikProps } from 'formik';
import { Plus } from 'lucide-react';
import {
ContainerInstanceFormValues,
ProviderViewModel,
ResourceGroup,
} from '@/react/azure/types';
import {
getSubscriptionLocations,
getSubscriptionResourceGroups,
} from '@/react/azure/container-instances/CreateView/utils';
import { PortsMappingField } from '@/react/azure/container-instances/CreateView/PortsMappingField';
import { AccessControlForm } from '@/react/portainer/access-control';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { FormControl } from '@@/form-components/FormControl';
import { Input, Select } from '@@/form-components/Input';
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
import { LoadingButton } from '@@/buttons';
import { Option } from '@@/form-components/PortainerSelect';
type Props = FormikProps<ContainerInstanceFormValues> & {
subscriptionOptions: Option<string>[];
environmentId: number;
resourceGroups: Record<string, ResourceGroup[]>;
providers: Record<string, ProviderViewModel | undefined>;
};
export function CreateContainerInstanceInnerForm({
errors,
handleSubmit,
isSubmitting,
isValid,
values,
setFieldValue,
environmentId,
subscriptionOptions,
resourceGroups,
providers,
}: Props) {
return (
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
<FormSectionTitle>Azure settings</FormSectionTitle>
<FormControl
label="Subscription"
inputId="subscription-input"
errors={errors.subscription}
>
<Field
name="subscription"
as={Select}
id="subscription-input"
options={subscriptionOptions}
/>
</FormControl>
<FormControl
label="Resource group"
inputId="resourceGroup-input"
errors={errors.resourceGroup}
>
<Field
name="resourceGroup"
as={Select}
id="resourceGroup-input"
options={getSubscriptionResourceGroups(
values.subscription,
resourceGroups
)}
/>
</FormControl>
<FormControl
label="Location"
inputId="location-input"
errors={errors.location}
>
<Field
name="location"
as={Select}
id="location-input"
options={getSubscriptionLocations(values.subscription, providers)}
/>
</FormControl>
<FormSectionTitle>Container configuration</FormSectionTitle>
<FormControl label="Name" inputId="name-input" errors={errors.name}>
<Field
name="name"
as={Input}
id="name-input"
placeholder="e.g. myContainer"
/>
</FormControl>
<FormControl label="Image" inputId="image-input" errors={errors.image}>
<Field
name="image"
as={Input}
id="image-input"
placeholder="e.g. nginx:alpine"
/>
</FormControl>
<FormControl label="OS" inputId="os-input" errors={errors.os}>
<Field
name="os"
as={Select}
id="os-input"
options={[
{ label: 'Linux', value: 'Linux' },
{ label: 'Windows', value: 'Windows' },
]}
/>
</FormControl>
<PortsMappingField
value={values.ports}
onChange={(value) => setFieldValue('ports', value)}
errors={errors.ports}
/>
<EnvironmentVariablesPanel
values={values.env}
onChange={(env) => setFieldValue('env', env)}
errors={errors.env}
/>
<div className="form-group">
<div className="col-sm-12 small text-muted">
This will automatically deploy a container with a public IP address
</div>
</div>
<FormSectionTitle>Container Resources</FormSectionTitle>
<FormControl label="CPU" inputId="cpu-input" errors={errors.cpu}>
<Field
name="cpu"
as={Input}
id="cpu-input"
type="number"
placeholder="1"
/>
</FormControl>
<FormControl label="Memory" inputId="cpu-input" errors={errors.memory}>
<Field
name="memory"
as={Input}
id="memory-input"
type="number"
placeholder="1"
/>
</FormControl>
<AccessControlForm
formNamespace="accessControl"
onChange={(values) => setFieldValue('accessControl', values)}
values={values.accessControl}
errors={errors.accessControl}
environmentId={environmentId}
/>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid}
isLoading={isSubmitting}
loadingText="Deployment in progress..."
icon={Plus}
data-cy="aci-create-button"
>
Deploy the container
</LoadingButton>
</div>
</div>
</Form>
);
}
@@ -79,7 +79,6 @@ export type SwarmCreatePayload =
git: GitFormModel;
relativePathSettings?: RelativePathModel;
fromAppTemplate?: boolean;
webhook?: string;
};
};
@@ -109,7 +108,6 @@ type StandaloneCreatePayload =
git: GitFormModel;
relativePathSettings?: RelativePathModel;
fromAppTemplate?: boolean;
webhook?: string;
};
};
@@ -128,7 +126,6 @@ type KubernetesCreatePayload =
payload: KubernetesBasePayload & {
git: GitFormModel;
relativePathSettings?: RelativePathModel;
webhook?: string;
};
}
| {
@@ -201,10 +198,7 @@ function createSwarmStack({ method, payload }: SwarmCreatePayload) {
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: transformAutoUpdateViewModel(
payload.git.AutoUpdate,
payload.webhook
),
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
environmentId: payload.environmentId,
swarmID: payload.swarmId,
additionalFiles: payload.git.AdditionalFiles,
@@ -252,10 +246,7 @@ function createStandaloneStack({ method, payload }: StandaloneCreatePayload) {
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: transformAutoUpdateViewModel(
payload.git.AutoUpdate,
payload.webhook
),
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
environmentId: payload.environmentId,
additionalFiles: payload.git.AdditionalFiles,
fromAppTemplate: payload.fromAppTemplate,
@@ -300,10 +291,7 @@ function createKubernetesStack({ method, payload }: KubernetesCreatePayload) {
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: transformAutoUpdateViewModel(
payload.git.AutoUpdate,
payload.webhook
),
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
environmentId: payload.environmentId,
additionalFiles: payload.git.AdditionalFiles,
composeFormat: payload.composeFormat,
+4 -4
View File
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { Alert } from './Alert';
@@ -21,21 +21,21 @@ function Template({ text, color, title }: Args) {
);
}
export const Success: StoryFn<Args> = Template.bind({});
export const Success: Story<Args> = Template.bind({});
Success.args = {
color: 'success',
title: 'Success',
text: 'This is a success alert. Very long text, Very long text,Very long text ,Very long text ,Very long text, Very long text',
};
export const Error: StoryFn<Args> = Template.bind({});
export const Error: Story<Args> = Template.bind({});
Error.args = {
color: 'error',
title: 'Error',
text: 'This is an error alert',
};
export const Info: StoryFn<Args> = Template.bind({});
export const Info: Story<Args> = Template.bind({});
Info.args = {
color: 'info',
title: 'Info',
+1 -1
View File
@@ -96,7 +96,7 @@ export function AlertContainer({
return (
<div
className={clsx(
'border rounded-xl border-solid [&_ul]:ps-8',
'border rounded-lg border-solid [&_ul]:ps-8',
'p-3',
className
)}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { Card, Props } from './Card';
@@ -14,5 +14,5 @@ function Template({
return <Card>{children}</Card>;
}
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {};
+4 -4
View File
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { Code } from './Code';
@@ -16,18 +16,18 @@ function Template({ text, showCopyButton }: Args) {
return <Code showCopyButton={showCopyButton}>{text}</Code>;
}
export const Primary: StoryFn<Args> = Template.bind({});
export const Primary: Story<Args> = Template.bind({});
Primary.args = {
text: 'curl -X GET http://ultra-sound-money.eth',
showCopyButton: true,
};
export const MultiLine: StoryFn<Args> = Template.bind({});
export const MultiLine: Story<Args> = Template.bind({});
MultiLine.args = {
text: 'curl -X\n GET http://example-with-children.crypto',
};
export const MultiLineWithIcon: StoryFn<Args> = Template.bind({});
export const MultiLineWithIcon: Story<Args> = Template.bind({});
MultiLineWithIcon.args = {
text: 'curl -X\n GET http://example-with-children.crypto',
showCopyButton: true,
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { List } from 'lucide-react';
import { Link } from '@@/Link';
@@ -29,7 +29,7 @@ function Template({ value, icon, type }: StoryProps) {
);
}
export const Primary: StoryFn<StoryProps> = Template.bind({});
export const Primary: Story<StoryProps> = Template.bind({});
Primary.args = {
value: 1,
icon: List,
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { DetailsTable } from './DetailsTable';
import { DetailsRow } from './DetailsRow';
@@ -24,7 +24,7 @@ function Template({ key1, val1, key2, val2 }: Args) {
);
}
export const Default: StoryFn<Args> = Template.bind({});
export const Default: Story<Args> = Template.bind({});
Default.args = {
key1: 'Name',
val1: 'My Cool App',
@@ -1,4 +1,4 @@
import { StoryFn, Meta } from '@storybook/react';
import { Story, Meta } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { InlineLoader, Props } from './InlineLoader';
@@ -12,7 +12,7 @@ function Template({ className, children }: PropsWithChildren<Props>) {
return <InlineLoader className={className}>{children}</InlineLoader>;
}
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
className: 'test-class',
children: 'Loading...',
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { InsightsBox, Props } from './InsightsBox';
@@ -11,7 +11,7 @@ function Template({ header, content }: Props) {
return <InsightsBox header={header} content={content} />;
}
export const Primary: StoryFn<Props> = Template.bind({});
export const Primary: Story<Props> = Template.bind({});
Primary.args = {
header: 'Insights box header',
content: 'This is the content of the insights box',
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { ComponentMeta, Story } from '@storybook/react';
import { useState } from 'react';
import { NavTabs, type Option } from './NavTabs';
@@ -6,7 +6,7 @@ import { NavTabs, type Option } from './NavTabs';
export default {
title: 'Components/NavTabs',
component: NavTabs,
} as Meta<typeof NavTabs>;
} as ComponentMeta<typeof NavTabs>;
type Args = {
options: Option[];
@@ -26,7 +26,7 @@ function Template({ options = [] }: Args) {
);
}
export const Example: StoryFn<Args> = Template.bind({});
export const Example: Story<Args> = Template.bind({});
Example.args = {
options: [
{ children: 'Content 1', id: 'option1', label: 'Option 1' },
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { useMemo } from 'react';
import { UserContext } from '@/react/hooks/useUser';
@@ -39,7 +39,7 @@ function Template({ title }: StoryProps) {
);
}
export const Primary: StoryFn<StoryProps> = Template.bind({});
export const Primary: Story<StoryProps> = Template.bind({});
Primary.args = {
title: 'Container details',
};
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { useMemo } from 'react';
import { UserContext } from '@/react/hooks/useUser';
@@ -37,7 +37,7 @@ function Template({ title }: StoryProps) {
);
}
export const Primary: StoryFn<StoryProps> = Template.bind({});
export const Primary: Story<StoryProps> = Template.bind({});
Primary.args = {
title: 'Container details',
};
@@ -66,6 +66,7 @@
.pagination > .active > span:focus,
.pagination > .active > button:focus {
@apply text-blue-7;
z-index: 3;
cursor: default;
/* background-color: var(--text-pagination-span-color); */
background-color: var(--bg-pagination-color);
+2 -2
View File
@@ -65,7 +65,7 @@ const SheetOverlay = forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={clsx(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
// eslint-disable-next-line react/jsx-props-no-spreading
@@ -76,7 +76,7 @@ const SheetOverlay = forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
'fixed gap-4 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
@@ -15,10 +15,10 @@ export function StickyFooter({
<div
className={clsx(
styles.actionBar,
'fixed bottom-0 right-0 z-40 h-16',
'fixed bottom-0 right-0 z-50 h-16',
'flex items-center px-6',
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
'shadow-[0_-2px_5px_rgba(0,0,0,0.1)]',
'shadow-[0_-2px_10px_rgba(0,0,0,0.1)]',
className
)}
>
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { TextTip } from './TextTip';
@@ -14,7 +14,7 @@ function Template({
return <TextTip>{children}</TextTip>;
}
export const Primary: StoryFn<PropsWithChildren<unknown>> = Template.bind({});
export const Primary: Story<PropsWithChildren<unknown>> = Template.bind({});
Primary.args = {
children: 'This is a text tip with children',
};
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { Tooltip, Props } from './Tooltip';
@@ -16,7 +16,7 @@ function Template({ message, position }: JSX.IntrinsicAttributes & Props) {
);
}
export const Primary: StoryFn<Props> = Template.bind({});
export const Primary: Story<Props> = Template.bind({});
Primary.args = {
message: 'Tooltip example',
position: 'bottom',
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { ViewLoading } from './ViewLoading';
@@ -15,7 +15,7 @@ function Template({ message }: Args) {
return <ViewLoading message={message} />;
}
export const Example: StoryFn<Args> = Template.bind({});
export const Example: Story<Args> = Template.bind({});
Example.args = {
message: 'Loading...',
};
@@ -1,142 +0,0 @@
import type { Meta, StoryFn } from '@storybook/react';
import { Box, Settings, Users } from 'lucide-react';
import {
ReactStateDeclaration,
UIRouter,
UIRouterReact,
UIView,
hashLocationPlugin,
servicesPlugin,
} from '@uirouter/react';
import { WidgetTabs, Tab } from './WidgetTabs';
// Create a UIRouter instance with a dummy state so `Link to="."` works
function withRouter(Story: () => JSX.Element) {
const router = new UIRouterReact();
router.plugin(servicesPlugin);
router.plugin(hashLocationPlugin);
// Register a dummy state that renders the Story
const storyState: ReactStateDeclaration = {
name: 'storybook',
url: '/?tab',
component: Story,
};
router.stateRegistry.register(storyState);
// Set initial state (UIRouter component calls start() automatically)
router.urlService.rules.initial({ state: 'storybook' });
router.urlService.rules.otherwise({ state: 'storybook' });
return (
<UIRouter router={router}>
<UIView />
</UIRouter>
);
}
const meta: Meta<typeof WidgetTabs> = {
title: 'Components/Widget/WidgetTabs',
component: WidgetTabs,
decorators: [withRouter],
};
export default meta;
const defaultTabs: Tab[] = [
{
name: 'Overview',
widget: <div>Overview content</div>,
selectedTabParam: 'overview',
},
{
name: 'Settings',
widget: <div>Settings content</div>,
selectedTabParam: 'settings',
},
{
name: 'Users',
widget: <div>Users content</div>,
selectedTabParam: 'users',
},
];
const tabsWithIcons: Tab[] = [
{
name: 'Overview',
icon: Box,
widget: <div>Overview content</div>,
selectedTabParam: 'overview',
},
{
name: 'Settings',
icon: Settings,
widget: <div>Settings content</div>,
selectedTabParam: 'settings',
},
{
name: 'Users',
icon: Users,
widget: <div>Users content</div>,
selectedTabParam: 'users',
},
];
interface StoryArgs {
currentTabIndex: number;
tabs: Tab[];
useContainer?: boolean;
}
function Template({ currentTabIndex, tabs, useContainer }: StoryArgs) {
return (
<WidgetTabs
currentTabIndex={currentTabIndex}
tabs={tabs}
useContainer={useContainer}
/>
);
}
export const Default: StoryFn<StoryArgs> = Template.bind({});
Default.args = {
currentTabIndex: 0,
tabs: defaultTabs,
};
export const WithIcons: StoryFn<StoryArgs> = Template.bind({});
WithIcons.args = {
currentTabIndex: 0,
tabs: tabsWithIcons,
};
export const SecondTabSelected: StoryFn<StoryArgs> = Template.bind({});
SecondTabSelected.args = {
currentTabIndex: 1,
tabs: tabsWithIcons,
};
export const WithoutContainer: StoryFn<StoryArgs> = Template.bind({});
WithoutContainer.args = {
currentTabIndex: 0,
tabs: tabsWithIcons,
useContainer: false,
};
export const TwoTabs: StoryFn<StoryArgs> = Template.bind({});
TwoTabs.args = {
currentTabIndex: 0,
tabs: [
{
name: 'Tab 1',
widget: <div>Tab 1 content</div>,
selectedTabParam: 'tab1',
},
{
name: 'Tab 2',
widget: <div>Tab 2 content</div>,
selectedTabParam: 'tab2',
},
],
};
@@ -1,173 +0,0 @@
import { render, screen } from '@testing-library/react';
import { Layers } from 'lucide-react';
import { ReactNode } from 'react';
import { vi } from 'vitest';
import { findSelectedTabIndex, Tab, WidgetTabs } from './WidgetTabs';
// Mock Link component to avoid ui-router relative state resolution in tests
vi.mock('@@/Link', () => ({
Link: ({
children,
'data-cy': dataCy,
'aria-current': ariaCurrent,
className,
}: {
children: ReactNode;
'data-cy'?: string;
'aria-current'?: 'page' | undefined;
className?: string;
}) => (
<a
data-cy={dataCy}
aria-current={ariaCurrent}
className={className}
href="/"
>
{children}
</a>
),
}));
const mockTabs: Tab[] = [
{
name: 'Overview',
widget: <div>Overview content</div>,
selectedTabParam: 'overview',
},
{
name: 'Details',
widget: <div>Details content</div>,
selectedTabParam: 'details',
},
{
name: 'Settings',
widget: <div>Settings content</div>,
selectedTabParam: 'settings',
},
];
function renderWidgetTabs(
props: Partial<React.ComponentProps<typeof WidgetTabs>> = {}
) {
const defaultProps = {
currentTabIndex: 0,
tabs: mockTabs,
};
return render(<WidgetTabs {...defaultProps} {...props} />);
}
describe('WidgetTabs', () => {
describe('rendering', () => {
it('renders all tabs and highlights the current tab', () => {
renderWidgetTabs({ currentTabIndex: 1 });
// All tabs should be visible
expect(screen.getByRole('link', { name: 'Overview' })).toBeVisible();
expect(screen.getByRole('link', { name: 'Details' })).toBeVisible();
expect(screen.getByRole('link', { name: 'Settings' })).toBeVisible();
// Only the selected tab should have aria-current="page"
expect(screen.getByRole('link', { name: 'Details' })).toHaveAttribute(
'aria-current',
'page'
);
expect(
screen.getByRole('link', { name: 'Overview' })
).not.toHaveAttribute('aria-current');
expect(
screen.getByRole('link', { name: 'Settings' })
).not.toHaveAttribute('aria-current');
});
it('renders tab icons when provided', () => {
const tabsWithIcons: Tab[] = [
{
name: 'Tab 1',
icon: Layers,
widget: <div />,
selectedTabParam: 'tab1',
},
{ name: 'Tab 2', widget: <div />, selectedTabParam: 'tab2' },
];
renderWidgetTabs({ tabs: tabsWithIcons, currentTabIndex: 0 });
// Tab with icon should contain an svg (lucide icon)
const tab1 = screen.getByRole('link', { name: 'Tab 1' });
expect(tab1.querySelector('svg')).toBeVisible();
// Tab without icon should not contain an svg
const tab2 = screen.getByRole('link', { name: 'Tab 2' });
expect(tab2.querySelector('svg')).toBeNull();
});
it('renders without container when useContainer is false', () => {
const { container } = renderWidgetTabs({ useContainer: false });
// Should not have the bootstrap row/col wrapper
expect(container.querySelector('.row')).toBeNull();
expect(container.querySelector('.col-sm-12')).toBeNull();
});
it('renders with container wrapper by default', () => {
const { container } = renderWidgetTabs();
// Should have the bootstrap row/col wrapper
expect(container.querySelector('.row')).toBeVisible();
expect(container.querySelector('.col-sm-12')).toBeVisible();
});
});
describe('error handling', () => {
it('throws an error when any tab has an invalid URL-encodable param value', () => {
// Tabs with characters that change when URL-encoded
const invalidTabs: Tab[] = [
{
name: 'Tab A',
widget: <div />,
selectedTabParam: 'param with spaces',
},
{ name: 'Tab B', widget: <div />, selectedTabParam: 'good-param' },
];
expect(() =>
renderWidgetTabs({ tabs: invalidTabs, currentTabIndex: 1 })
).toThrow('Invalid query param value for tab');
});
});
describe('accessibility', () => {
it('has accessible navigation landmark with label', () => {
renderWidgetTabs();
const nav = screen.getByRole('navigation', {
name: 'Section navigation',
});
expect(nav).toBeVisible();
});
});
});
describe('findSelectedTabIndex', () => {
it('returns the correct index when tab param matches', () => {
const result = findSelectedTabIndex({ tab: 'details' }, mockTabs);
expect(result).toBe(1);
});
it('returns 0 when tab param does not match any tab', () => {
const result = findSelectedTabIndex({ tab: 'nonexistent' }, mockTabs);
expect(result).toBe(0);
});
it('returns 0 when params.tab is undefined', () => {
const result = findSelectedTabIndex({}, mockTabs);
expect(result).toBe(0);
});
it('returns the index of the first tab when params.tab matches first tab', () => {
const result = findSelectedTabIndex({ tab: 'overview' }, mockTabs);
expect(result).toBe(0);
});
});
+35 -57
View File
@@ -15,76 +15,54 @@ export interface Tab {
interface Props {
currentTabIndex: number;
tabs: Tab[];
useContainer?: boolean;
ariaLabel?: string;
}
export function WidgetTabs({
currentTabIndex,
tabs,
useContainer = true,
ariaLabel = 'Section navigation',
}: Props) {
// https://www.figma.com/file/g5TUMngrblkXM7NHSyQsD1/New-UI?type=design&node-id=148-2676&mode=design&t=JKyBWBupeC5WADk6-0
export function WidgetTabs({ currentTabIndex, tabs }: Props) {
// ensure that the selectedTab param is always valid
const invalidQueryParamValue = tabs.some(
const invalidQueryParamValue = tabs.every(
(tab) => encodeURIComponent(tab.selectedTabParam) !== tab.selectedTabParam
);
if (invalidQueryParamValue) {
throw new Error('Invalid query param value for tab');
}
const tabsComponent = (
<nav
aria-label={ariaLabel}
className={clsx(
'max-w-fit overflow-hidden rounded-xl',
'bg-[var(--bg-widget-color)] border border-solid border-[var(--border-widget)]'
)}
>
{/* additional div, so that the scrollbar doesn't overlap with rounded corners of the nav parent */}
<div className="flex items-center gap-1 p-1 overflow-x-auto">
{tabs.map(({ name, icon }, index) => (
<Link
to="."
params={{ tab: tabs[index].selectedTabParam }}
key={index}
className={clsx(
'inline-flex items-center gap-2 px-4 py-2 rounded-lg',
'hover:no-underline focus:no-underline text-gray-7 th-highcontrast:text-white th-dark:text-gray-6',
'transition-colors duration-200',
{
'border-inherit !bg-graphite-50 !text-graphite-900 hover:text-graphite-900 th-dark:!bg-graphite-600 th-dark:!text-white th-highcontrast:!bg-white th-highcontrast:!text-black':
currentTabIndex === index,
},
{
'bg-transparent hover:bg-graphite-50 th-dark:hover:bg-graphite-600 th-highcontrast:hover:bg-white hover:text-gray-7 th-dark:hover:text-gray-6 th-highcontrast:hover:text-black':
currentTabIndex !== index,
}
)}
data-cy={`tab-${index}`}
aria-current={currentTabIndex === index ? 'page' : undefined}
>
{icon && <Icon icon={icon} />}
{name}
</Link>
))}
return (
<div className="row">
<div className="col-sm-12 !mb-0">
<div className="pl-2">
{tabs.map(({ name, icon }, index) => (
<Link
to="."
params={{ tab: tabs[index].selectedTabParam }}
key={index}
className={clsx(
'inline-flex items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2 hover:no-underline',
{
'border-blue-8 text-blue-8 th-highcontrast:border-blue-6 th-highcontrast:text-blue-6 th-dark:border-blue-6 th-dark:text-blue-6':
currentTabIndex === index,
'border-transparent text-gray-7 hover:text-gray-8 th-highcontrast:text-gray-6 hover:th-highcontrast:text-gray-5 th-dark:text-gray-6 hover:th-dark:text-gray-5':
currentTabIndex !== index,
}
)}
data-cy={`tab-${index}`}
>
{icon && <Icon icon={icon} />}
{name}
</Link>
))}
</div>
</div>
</nav>
</div>
);
if (useContainer) {
return (
<div className="row">
<div className="col-sm-12">{tabsComponent}</div>
</div>
);
}
return tabsComponent;
}
// findSelectedTabIndex returns the index of the tab, or 0 if none is selected
export function findSelectedTabIndex(params: RawParams, tabs: Tab[]) {
export function findSelectedTabIndex(
{ params }: { params: RawParams },
tabs: Tab[]
) {
const selectedTabParam = params.tab || tabs[0].selectedTabParam;
const currentTabIndex = tabs.findIndex(
(tab) => tab.selectedTabParam === selectedTabParam
@@ -97,7 +75,7 @@ export function findSelectedTabIndex(params: RawParams, tabs: Tab[]) {
export function useCurrentTabIndex(tabs: Tab[]) {
const params = useCurrentStateAndParams();
const currentTabIndex = findSelectedTabIndex(params.params, tabs);
const currentTabIndex = findSelectedTabIndex(params, tabs);
return currentTabIndex;
}
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { AddButton } from './AddButton';
@@ -15,7 +15,7 @@ function Template({ label }: Args) {
return <AddButton data-cy="add-">{label}</AddButton>;
}
export const Primary: StoryFn<Args> = Template.bind({});
export const Primary: Story<Args> = Template.bind({});
Primary.args = {
label: 'Create new container',
};
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { Download } from 'lucide-react';
@@ -85,7 +85,7 @@ function Template({
);
}
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
color: 'primary',
size: 'small',
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { Play, RefreshCw, Square, Trash2 } from 'lucide-react';
@@ -45,7 +45,7 @@ function Template({
);
}
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
size: 'small',
};
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { CopyButton, Props } from './CopyButton';
@@ -24,13 +24,13 @@ function Template({
);
}
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
Primary.args = {
children: 'Copy',
copyText: 'this will be copied to clipboard',
};
export const NoCopyText: StoryFn<PropsWithChildren<Props>> = Template.bind({});
export const NoCopyText: Story<PropsWithChildren<Props>> = Template.bind({});
NoCopyText.args = {
children: 'Copy without copied text',
copyText: 'clipboard override',
+3 -12
View File
@@ -1,13 +1,12 @@
import { Trash2 } from 'lucide-react';
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { confirmDelete } from '@@/modals/confirm';
import { LoadingButton } from './LoadingButton';
import { Button } from './Button';
import { LoadingButton } from './LoadingButton';
type ConfirmOrClick =
| {
@@ -27,10 +26,7 @@ export function DeleteButton({
size,
children,
isLoading,
text = 'Remove',
loadingText = 'Removing...',
icon = false,
type,
'data-cy': dataCy,
...props
}: PropsWithChildren<
@@ -39,10 +35,7 @@ export function DeleteButton({
size?: ComponentProps<typeof Button>['size'];
disabled?: boolean;
isLoading?: boolean;
text?: string;
loadingText?: string;
icon?: boolean;
type?: ComponentProps<typeof Button>['type'];
}
>) {
if (isLoading === undefined) {
@@ -53,11 +46,10 @@ export function DeleteButton({
disabled={disabled || isLoading}
onClick={() => handleClick()}
icon={Trash2}
className={clsx('!m-0', icon ? 'btn-icon' : '')}
className="!m-0"
data-cy={dataCy}
type={type}
>
{children || text}
{children || 'Remove'}
</Button>
);
}
@@ -73,7 +65,6 @@ export function DeleteButton({
data-cy={dataCy}
isLoading={isLoading}
loadingText={loadingText}
type={type}
>
{children || 'Remove'}
</LoadingButton>
+9 -21
View File
@@ -7,43 +7,31 @@ interface Props<D extends DefaultType = DefaultType> {
cells: Cell<D, unknown>[];
className?: string;
onClick?: () => void;
'aria-selected'?: boolean;
}
export function TableRow<D extends DefaultType = DefaultType>({
cells,
className,
onClick,
'aria-selected': ariaSelected,
}: Props<D>) {
return (
<tr
className={clsx(className, { 'cursor-pointer': !!onClick })}
onClick={onClick}
aria-selected={ariaSelected}
>
{cells.map((cell) => {
const { className, width } = parseMeta(cell.column.columnDef.meta);
return (
<td key={cell.id} className={className} style={{ width }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
{cells.map((cell) => (
<td key={cell.id} className={getClassName(cell.column.columnDef.meta)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
}
function parseMeta<D extends DefaultType = DefaultType>(
function getClassName<D extends DefaultType = DefaultType>(
meta: ColumnMeta<D, unknown> | undefined
) {
const className =
!!meta && 'className' in meta && typeof meta.className === 'string'
? meta.className
: '';
const width =
!!meta && 'width' in meta && typeof meta.width === 'string'
? meta.width
: undefined;
return { className, width };
return !!meta && 'className' in meta && typeof meta.className === 'string'
? meta.className
: '';
}
@@ -3,8 +3,8 @@
overflow: auto;
padding: 20px;
font-size: 16px;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
display: flex;
align-items: center;
@@ -100,8 +100,8 @@
overflow: auto;
border-top: 1px solid var(--border-datatable-top-color);
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
padding-bottom: 10px;
}
@@ -67,7 +67,7 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
),
enableHiding: false,
meta: {
width: '50px',
width: 50,
},
};
}
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { FormSection } from './FormSection';
@@ -25,7 +25,7 @@ const exampleContent = `Content
Nullam nec nibh maximus, consequat quam sed, dapibus purus. Donec facilisis commodo mi, in commodo augue molestie sed.
`;
export const Example: StoryFn<Args> = Template.bind({});
export const Example: Story<Args> = Template.bind({});
Example.args = {
title: 'title',
content: exampleContent,
@@ -52,7 +52,7 @@ export function FormSection({
</FormSectionTitle>
{/* col-sm-12 in the title has a 'float: left' style - 'clear-both' makes sure it doesn't get in the way of the next div */}
{/* https://stackoverflow.com/questions/7759837/put-divs-below-floatleft-divs */}
<div className="clear-both">{isExpanded && children}</div>
{isExpanded && <div className="clear-both">{children}</div>}
</div>
);
}
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { PropsWithChildren } from 'react';
import { FormSectionTitle } from './FormSectionTitle';
@@ -14,7 +14,7 @@ function Template({
return <FormSectionTitle>{children}</FormSectionTitle>;
}
export const Example: StoryFn<PropsWithChildren<unknown>> = Template.bind({});
export const Example: Story<PropsWithChildren<unknown>> = Template.bind({});
Example.args = {
children: 'This is a title with children',
};
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { useState } from 'react';
import { Input } from './Input';
@@ -27,7 +27,7 @@ export function TextField({ disabled }: Args) {
);
}
export const DisabledTextField: StoryFn<Args> = TextField.bind({});
export const DisabledTextField: Story<Args> = TextField.bind({});
DisabledTextField.args = {
disabled: true,
};
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { useState } from 'react';
import { Select } from './Select';
@@ -31,7 +31,7 @@ export function Example({ disabled }: Args) {
);
}
export const DisabledSelect: StoryFn<Args> = Example.bind({});
export const DisabledSelect: Story<Args> = Example.bind({});
DisabledSelect.args = {
disabled: true,
};
@@ -18,6 +18,7 @@ export function Select<T extends number | string>({
options,
className,
'data-cy': dataCy,
id,
...props
}: Props<T> & SelectHTMLAttributes<HTMLSelectElement>) {
return (
@@ -178,10 +178,6 @@
z-index: 10;
}
.portainer-selector__menu-portal {
z-index: 50 !important;
}
.portainer-selector__menu-portal .portainer-selector__menu .portainer-selector__option,
.portainer-selector-root .portainer-selector__menu .portainer-selector__option {
background-color: var(--bg-multiselect-color);
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { useEffect, useState } from 'react';
import { Slider, Props } from './Slider';
@@ -41,7 +41,7 @@ function Template({
);
}
export const Primary: StoryFn<Props> = Template.bind({});
export const Primary: Story<Props> = Template.bind({});
Primary.args = {
min: 0,
max: 100,
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { useState } from 'react';
import { Switch } from './Switch';
@@ -40,12 +40,12 @@ function Template({ checked }: Args) {
);
}
export const Checked: StoryFn<Args> = Template.bind({});
export const Checked: Story<Args> = Template.bind({});
Checked.args = {
checked: true,
};
export const Unchecked: StoryFn<Args> = Template.bind({});
export const Unchecked: Story<Args> = Template.bind({});
Unchecked.args = {
checked: false,
};
@@ -1,4 +1,4 @@
import { Meta, StoryFn } from '@storybook/react';
import { Meta, Story } from '@storybook/react';
import { useState } from 'react';
import { SwitchField } from './SwitchField';
@@ -43,14 +43,14 @@ function Template({ checked, label, labelClass }: Args) {
);
}
export const Checked: StoryFn<Args> = Template.bind({});
export const Checked: Story<Args> = Template.bind({});
Checked.args = {
checked: true,
label: 'label',
labelClass: 'col-sm-6',
};
export const Unchecked: StoryFn<Args> = Template.bind({});
export const Unchecked: Story<Args> = Template.bind({});
Unchecked.args = {
checked: false,
label: 'label',
+4 -4
View File
@@ -37,11 +37,11 @@ export function Modal({
<Context.Provider value>
<DialogOverlay
isOpen
className={clsx(styles.overlay, 'flex items-center justify-center')}
className={clsx(
styles.overlay,
'flex items-center justify-center z-50'
)}
onDismiss={onDismiss}
// When a Sheet is open and then a Modal opens, Radix DismissableLayer sets body.style.pointerEvents="none" for this modal overlay, so make it auto here.
// z-index ensures the modal renders above the base views and any Sheet (z-50).
style={{ zIndex: 60, pointerEvents: 'auto' }}
>
<DialogContent
aria-label={ariaLabel}
@@ -24,7 +24,7 @@ import { useSystemLimits, useIsWindows } from '../../proxy/queries/useInfo';
import { useCreateOrReplaceMutation } from './useCreateMutation';
import { useValidation } from './validation';
import { useInitialValues, Values } from './useInitialValues';
import { CreateInnerForm } from './CreateInnerForm';
import { InnerForm } from './InnerForm';
import { toRequest } from './toRequest';
export function CreateView() {
@@ -49,7 +49,9 @@ function CreateForm() {
const router = useRouter();
const isWindows = useIsWindows(environmentId);
const isAdminQuery = useIsEdgeAdmin();
const { authorized: isEnvironmentAdmin } = useIsEnvironmentAdmin();
const { authorized: isEnvironmentAdmin } = useIsEnvironmentAdmin({
adminOnlyCE: true,
});
const [isDockerhubRateLimited, setIsDockerhubRateLimited] = useState(false);
const mutation = useCreateOrReplaceMutation();
@@ -82,6 +84,7 @@ function CreateForm() {
const environment = envQuery.data;
// if windows, hide capabilities. this is because capadd and capdel are not supported on windows
const hideCapabilities =
(!environment.SecuritySettings.allowContainerCapabilitiesForRegularUsers &&
!isEnvironmentAdmin) ||
@@ -119,7 +122,7 @@ function CreateForm() {
validateOnMount
validationSchema={validationSchema}
>
<CreateInnerForm
<InnerForm
hideCapabilities={hideCapabilities}
onChangeName={syncName}
isDuplicate={isDuplicating}
@@ -23,7 +23,7 @@ import { Values } from './useInitialValues';
import { EnvVarsTab } from './EnvVarsTab';
import { EditResourcesForm } from './ResourcesTab/EditResourceForm';
export function CreateInnerForm({
export function InnerForm({
hideCapabilities = false,
isLoading,
isDuplicate,
@@ -60,7 +60,6 @@ vi.mock('@/portainer/services/notifications', () => ({
describe('ImportExportButtons', () => {
beforeEach(() => {
vi.clearAllMocks();
server.resetHandlers();
mockConfirmImageExport.mockResolvedValue(false);
});
@@ -380,9 +379,9 @@ describe('ImportExportButtons', () => {
await waitFor(() => {
expect(mockConfirmImageExport).toHaveBeenCalled();
expect(apiCalled).toBe(false);
expect(mockSaveAs).not.toHaveBeenCalled();
});
expect(apiCalled).toBe(false);
expect(mockSaveAs).not.toHaveBeenCalled();
});
});
@@ -31,15 +31,6 @@ function renderComponent({
}
describe('CreateStackForm', () => {
it.skip('should not display any visible error messages on initial load', async () => {
renderComponent();
const errors = await screen.findByRole('alert');
// Check that no error messages (role="alert") are visible
expect(errors).not.toBeInTheDocument();
});
it('should render with default method (editor)', async () => {
renderComponent();
@@ -111,15 +102,17 @@ describe('CreateStackForm', () => {
it('should show validation error for empty name', async () => {
renderComponent();
expect(await screen.findByText(/name is required/i)).toBeVisible();
await waitFor(() => {
expect(screen.getByText(/name is required/i)).toBeVisible();
});
});
it('should show validation error for empty editor content', async () => {
renderComponent();
expect(
await screen.findByText(/stack file content is required/i)
).toBeVisible();
await waitFor(() => {
expect(screen.getByText(/stack file content is required/i)).toBeVisible();
});
});
it('should submit editor form successfully', async () => {
@@ -1,7 +1,5 @@
import { Formik } from 'formik';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { useState } from 'react';
import uuidv4 from 'uuid/v4';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifySuccess } from '@/portainer/services/notifications';
@@ -15,7 +13,7 @@ import { getDefaultModel } from '@/react/portainer/gitops/types';
import { FormValues } from './types';
import { useValidationSchema } from './useValidationSchema';
import { CreateStackInnerForm } from './CreateStackInnerForm';
import { InnerForm } from './InnerForm';
interface Props {
environmentId: EnvironmentId;
@@ -31,7 +29,7 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) {
const createStackMutation = useCreateStack();
const { user } = useCurrentUser();
const { isAdmin } = useIsEdgeAdmin();
const [webhookId] = useState(() => uuidv4());
const validationSchema = useValidationSchema(environmentId);
const initialValues: FormValues = {
@@ -54,7 +52,7 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) {
variables: [],
},
env: [],
enableWebhook: false,
webhookId: '',
registries: [],
accessControl: defaultValues(isAdmin, user.Id),
};
@@ -66,11 +64,10 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) {
validationSchema={validationSchema}
validateOnMount
>
<CreateStackInnerForm
<InnerForm
isDeploying={createStackMutation.isLoading}
isSwarm={isSwarm}
isSaved={createStackMutation.isSuccess}
webhookId={webhookId}
/>
</Formik>
);
@@ -78,13 +75,12 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) {
async function handleSubmit(values: FormValues) {
const stackType = isSwarm ? 'swarm' : 'standalone';
const payload = buildCreateStackPayload({
const payload = buildCreateStackPayload(
values,
environmentId,
stackType,
swarmId,
webhookId,
});
swarmId
);
createStackMutation.mutate(payload, {
onSuccess: () => {
@@ -95,19 +91,12 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) {
}
}
function buildCreateStackPayload({
environmentId,
stackType,
swarmId,
values,
webhookId,
}: {
values: FormValues;
environmentId: EnvironmentId;
stackType: 'swarm' | 'standalone';
swarmId: string;
webhookId: string;
}): CreateStackPayload {
function buildCreateStackPayload(
values: FormValues,
environmentId: EnvironmentId,
stackType: 'swarm' | 'standalone',
swarmId: string
): CreateStackPayload {
const basePayload = {
name: values.name,
environmentId,
@@ -126,7 +115,7 @@ function buildCreateStackPayload({
...basePayload,
swarmId,
fileContent: values.editor.fileContent,
webhook: values.enableWebhook ? webhookId : undefined,
webhook: values.webhookId,
},
};
}
@@ -136,7 +125,7 @@ function buildCreateStackPayload({
payload: {
...basePayload,
fileContent: values.editor.fileContent,
webhook: values.enableWebhook ? webhookId : undefined,
webhook: values.webhookId,
},
};
@@ -152,7 +141,7 @@ function buildCreateStackPayload({
...basePayload,
swarmId,
file: values.upload.file,
webhook: values.enableWebhook ? webhookId : undefined,
webhook: values.webhookId,
},
};
}
@@ -162,7 +151,7 @@ function buildCreateStackPayload({
payload: {
...basePayload,
file: values.upload.file,
webhook: values.enableWebhook ? webhookId : undefined,
webhook: values.webhookId,
},
};
@@ -173,7 +162,6 @@ function buildCreateStackPayload({
method: 'git',
payload: {
...basePayload,
webhook: webhookId,
swarmId,
git: values.git,
relativePathSettings: values.git.SupportRelativePath
@@ -195,7 +183,6 @@ function buildCreateStackPayload({
payload: {
...basePayload,
git: values.git,
webhook: webhookId,
relativePathSettings: values.git.SupportRelativePath
? {
SupportRelativePath: true,
@@ -218,7 +205,7 @@ function buildCreateStackPayload({
...basePayload,
swarmId,
fileContent: values.template.fileContent,
webhook: values.enableWebhook ? webhookId : undefined,
webhook: values.webhookId,
fromAppTemplate: true,
},
};
@@ -229,7 +216,7 @@ function buildCreateStackPayload({
payload: {
...basePayload,
fileContent: values.template.fileContent,
webhook: values.enableWebhook ? webhookId : undefined,
webhook: values.webhookId,
fromAppTemplate: true,
},
};
@@ -1,343 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DefaultBodyType, http, HttpResponse } from 'msw';
import uuidv4 from 'uuid/v4';
import { Formik } from 'formik';
import { server } from '@/setup-tests/server';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { createMockUsers } from '@/react-tools/test-mocks';
import { Role } from '@/portainer/users/types';
import { mockFormValues } from './test-utils';
import { CreateStackInnerForm } from './CreateStackInnerForm';
import { CreateStackForm } from './CreateStackForm';
vi.mock('uuid/v4', () => ({
default: vi.fn(() => 'test-webhook-id-1234'),
}));
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { endpointId: 1 },
})),
}));
describe('CreateStackForm - Webhook ID Integration', () => {
beforeEach(() => {
vi.clearAllMocks();
server.use(
http.get('/api/endpoints/1', () =>
HttpResponse.json({
Id: 1,
Type: EnvironmentType.Docker,
ComposeSyntaxMaxVersion: '3',
ChangeWindow: {
Enabled: false,
},
})
)
);
});
it('should display webhook ID when webhook is enabled in editor method', async () => {
const webhookId = 'test-webhook-id-1234';
renderComponent({
webhookId,
initialValues: mockFormValues({
method: 'editor',
name: 'test-stack',
enableWebhook: true,
editor: {
fileContent: "version: '3'\nservices:\n web:\n image: nginx",
},
}),
});
await waitFor(() => {
expect(screen.getByTestId('stack-name-input')).toBeInTheDocument();
});
await waitFor(() => {
const webhookDisplay = screen.queryByRole('textbox', {
name: /webhook url/i,
});
expect(webhookDisplay).toBeInTheDocument();
expect(webhookDisplay).toHaveTextContent(webhookId);
});
expect(vi.mocked(uuidv4)).not.toHaveBeenCalled();
});
it('should display webhook ID in git repository method with auto-update', async () => {
const webhookId = 'test-webhook-id-1234';
server.use(
http.post('/api/gitops/repo/refs', () =>
HttpResponse.json(['refs/heads/main', 'refs/heads/develop'])
),
http.post('/api/gitops/repo/files/search', () =>
HttpResponse.json(['docker-compose.yml'])
)
);
renderComponent({
webhookId,
initialValues: mockFormValues({
method: 'repository',
name: 'test-stack',
git: {
RepositoryURL: 'https://github.com/test/repo',
RepositoryReferenceName: 'main',
ComposeFilePathInRepository: 'docker-compose.yml',
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryGitCredentialID: 0,
TLSSkipVerify: false,
AdditionalFiles: [],
AutoUpdate: {
RepositoryAutomaticUpdates: true,
RepositoryMechanism: 'Webhook',
RepositoryFetchInterval: '',
ForcePullImage: false,
RepositoryAutomaticUpdatesForce: false,
},
RepositoryAuthorizationType: undefined,
SupportRelativePath: false,
FilesystemPath: '',
SaveCredential: false,
NewCredentialName: '',
},
}),
});
await waitFor(() => {
expect(screen.getByTestId('stack-name-input')).toBeInTheDocument();
});
await waitFor(
() => {
const webhookDisplay = screen.queryByRole('textbox', {
name: /webhook url/i,
});
expect(webhookDisplay).toBeInTheDocument();
expect(webhookDisplay).toHaveTextContent(webhookId);
},
{ timeout: 3000 }
);
});
it('should not display webhook ID when webhook is disabled', async () => {
const webhookId = 'test-webhook-id-1234';
renderComponent({
webhookId,
initialValues: mockFormValues({
method: 'editor',
name: 'test-stack',
enableWebhook: false,
editor: {
fileContent: "version: '3'\nservices:\n web:\n image: nginx",
},
}),
});
await waitFor(() => {
expect(screen.getByTestId('stack-name-input')).toBeInTheDocument();
});
const webhookDisplay = screen.queryByRole('textbox', {
name: /webhook url/i,
});
expect(webhookDisplay).not.toBeInTheDocument();
});
it('should call uuid exactly once when CreateStackForm mounts', async () => {
vi.clearAllMocks();
renderFullComponent({
environmentId: 1,
isSwarm: false,
swarmId: '',
});
await waitFor(() => {
expect(screen.getByTestId('stack-name-input')).toBeInTheDocument();
});
expect(vi.mocked(uuidv4)).toHaveBeenCalledOnce();
});
it('should send webhook ID in API request when editor webhook is enabled', async () => {
const user = userEvent.setup();
let capturedRequestBody: DefaultBodyType;
server.use(
http.post('/api/stacks/create/standalone/string', async ({ request }) => {
capturedRequestBody = await request.json();
return HttpResponse.json({
Id: 1,
Name: 'test-stack',
ResourceControl: { Id: 1 },
});
}),
http.put('/api/resource_controls/:id', () => HttpResponse.json({}))
);
renderFullComponent({
environmentId: 1,
isSwarm: false,
swarmId: '',
});
const nameInput = await screen.findByRole('textbox', { name: /name/i });
await user.clear(nameInput);
await user.type(nameInput, 'test-stack');
const editor = screen.getByTestId('stack-creation-editor');
await user.type(editor, 'services:\n web:\n image: nginx');
const webhookToggle = await screen.findByRole('checkbox', {
name: /create a stack webhook/i,
});
await user.click(webhookToggle);
await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
const deployButton = screen.getByRole('button', {
name: /deploy the stack/i,
});
expect(deployButton).toBeEnabled();
await user.click(deployButton);
await waitFor(
() => {
expect(capturedRequestBody).toBeDefined();
},
{ timeout: 3000 }
);
assert(capturedRequestBody && typeof capturedRequestBody === 'object');
expect(capturedRequestBody?.webhook).toBe('test-webhook-id-1234');
});
it('should not send webhook ID in API request when editor webhook is disabled', async () => {
const user = userEvent.setup();
let capturedRequestBody: DefaultBodyType;
server.use(
http.post('/api/stacks/create/standalone/string', async ({ request }) => {
capturedRequestBody = await request.json();
return HttpResponse.json({
Id: 1,
Name: 'test-stack',
ResourceControl: { Id: 1 },
});
}),
http.put('/api/resource_controls/:id', () => HttpResponse.json({}))
);
renderFullComponent({
environmentId: 1,
isSwarm: false,
swarmId: '',
});
const nameInput = await screen.findByRole('textbox', { name: /name/i });
await user.clear(nameInput);
await user.type(nameInput, 'test-stack');
const editor = screen.getByTestId('stack-creation-editor');
await user.type(editor, 'services:\n web:\n image: nginx');
await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
const deployButton = screen.getByRole('button', {
name: /deploy the stack/i,
});
expect(deployButton).toBeEnabled();
await user.click(deployButton);
await waitFor(
() => {
expect(capturedRequestBody).toBeDefined();
},
{ timeout: 3000 }
);
expect(capturedRequestBody).not.toHaveProperty('webhook');
});
});
function renderComponent({
webhookId,
initialValues,
}: {
webhookId: string;
initialValues: ReturnType<typeof mockFormValues>;
}) {
const user = createMockUsers(1, Role.Admin)[0];
const Component = withTestRouter(
withUserProvider(
withTestQueryProvider(() => (
<Formik
initialValues={initialValues}
onSubmit={async () => {}}
validateOnMount
>
<CreateStackInnerForm
isSwarm={false}
isDeploying={false}
isSaved={false}
webhookId={webhookId}
/>
</Formik>
)),
user
)
);
return render(<Component />);
}
function renderFullComponent({
environmentId,
isSwarm,
swarmId,
}: {
environmentId: number;
isSwarm: boolean;
swarmId: string;
}) {
const user = createMockUsers(1, Role.Admin)[0];
const Component = withTestRouter(
withUserProvider(
withTestQueryProvider(() => (
<CreateStackForm
environmentId={environmentId}
isSwarm={isSwarm}
swarmId={swarmId}
/>
)),
user
)
);
return render(<Component />);
}

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