Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f99e298f3 | |||
| 21eb20b35e | |||
| f85a7ea24c | |||
| 6aacb61c87 | |||
| bb2c75ba93 | |||
| 16536c8a71 | |||
| 9fcac1ab4f | |||
| ae24ad4693 | |||
| 0f721b60a9 | |||
| e8b49f53e1 | |||
| 27531a802b | |||
| 4bbf0ce0c0 | |||
| e0c22ea3eb | |||
| b7eb2ba068 | |||
| affdb69568 | |||
| 763b7da65c | |||
| 42e9165347 | |||
| 16dd08a359 | |||
| 936494615c | |||
| 5769c0b98e | |||
| b7e1caa8c6 | |||
| e02ae6b2fb | |||
| d9f131a2c5 | |||
| ad1f7dbaa5 | |||
| aa6da0f6d3 | |||
| 376071e408 | |||
| d3544fb9b3 | |||
| c8497b3944 | |||
| 5aa92b8413 | |||
| bccb6694d4 | |||
| 506a11c658 | |||
| bdc315a59d | |||
| ec7d3bddfc | |||
| 762c1ccf28 | |||
| 8e44c8fa06 | |||
| 20db102327 | |||
| 1643cb8165 | |||
| 49e623dfeb | |||
| a1208974ac | |||
| d611087513 | |||
| ac7cb2ee19 | |||
| f866572cbf | |||
| 4c6942f60b | |||
| d939897524 | |||
| 66c5589fd7 | |||
| 379b1d611b | |||
| f16221f385 | |||
| 9b82560270 | |||
| 7271af03e6 | |||
| 4d564bbce2 | |||
| d7afdf214b | |||
| 18e445ea02 | |||
| cb70c705a3 | |||
| 9a77eb9872 | |||
| ec82f646a0 | |||
| 2f0e384240 | |||
| 19a1426869 | |||
| cc5cd8db6b | |||
| e384e2edda | |||
| dca044873f | |||
| 8aadddcc68 | |||
| 2e95229c51 |
+6
-2
@@ -139,15 +139,18 @@ overrides:
|
||||
'react/jsx-props-no-spreading': off
|
||||
- files:
|
||||
- app/**/*.test.*
|
||||
plugins:
|
||||
- '@vitest'
|
||||
extends:
|
||||
- 'plugin:vitest/recommended'
|
||||
- 'plugin:@vitest/legacy-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:
|
||||
@@ -155,3 +158,4 @@ overrides:
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
'storybook/no-renderer-packages': off
|
||||
|
||||
@@ -94,10 +94,13 @@ 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'
|
||||
|
||||
@@ -6,7 +6,7 @@ linters:
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
|
||||
msg: Use a transaction instead
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
|
||||
+2
-1
@@ -1,2 +1,3 @@
|
||||
dist
|
||||
api/datastore/test_data
|
||||
api/datastore/test_data
|
||||
coverage
|
||||
+29
-11
@@ -9,20 +9,38 @@ const config: StorybookConfig = {
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-webpack5-compiler-swc',
|
||||
'@chromatic-com/storybook',
|
||||
{
|
||||
name: '@storybook/addon-styling',
|
||||
name: '@storybook/addon-styling-webpack',
|
||||
|
||||
options: {
|
||||
cssLoaderOptions: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
postCss: {
|
||||
implementation: postcss,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+17
-18
@@ -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,31 +21,30 @@ 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 } },
|
||||
});
|
||||
|
||||
export const decorators = [
|
||||
(Story) => (
|
||||
const preview: Preview = {
|
||||
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 const loaders = [mswLoader];
|
||||
export default preview;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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)
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
)
|
||||
|
||||
@@ -108,7 +109,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
case tar.TypeDir:
|
||||
// skip, dir will be created with a file
|
||||
case tar.TypeReg:
|
||||
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
|
||||
p := filesystem.JoinPaths(outputDirPath, header.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
||||
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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"
|
||||
@@ -108,3 +111,56 @@ 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)
|
||||
}
|
||||
|
||||
+3
-1
@@ -92,7 +92,9 @@ func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Con
|
||||
}
|
||||
|
||||
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||
if !config.TLS {
|
||||
if !config.TLS && fipsEnabled {
|
||||
return nil, fips.ErrTLSRequired
|
||||
} else if !config.TLS {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
||||
}
|
||||
}
|
||||
|
||||
if e := json.Unmarshal(data, object); e != nil {
|
||||
if err := json.Unmarshal(data, object); err != 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, e.Error())
|
||||
return errors.Wrap(err, "Failed unmarshalling object")
|
||||
}
|
||||
|
||||
*s = string(data)
|
||||
|
||||
@@ -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://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
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")
|
||||
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")
|
||||
)
|
||||
|
||||
@@ -31,6 +31,13 @@ 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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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)
|
||||
}
|
||||
@@ -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://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
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")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -74,7 +74,9 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
|
||||
return tx.store.SnapshotService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService {
|
||||
return tx.store.SSLSettingsService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
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.38.1",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.39.0",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -942,7 +942,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.38.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.39.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
@@ -35,8 +36,10 @@ 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 }()
|
||||
@@ -44,8 +47,17 @@ 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
|
||||
}
|
||||
@@ -56,6 +68,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
stopped += stat.Stopped
|
||||
healthy += stat.Healthy
|
||||
unhealthy += stat.Unhealthy
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
@@ -67,7 +80,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: len(containers),
|
||||
Total: processedCount,
|
||||
}, aggErr
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ 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"
|
||||
@@ -37,6 +39,7 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
{ID: "container8"},
|
||||
{ID: "container9"},
|
||||
{ID: "container10"},
|
||||
{ID: "container11"},
|
||||
}
|
||||
|
||||
// Setup mock expectations with different container states to test various scenarios
|
||||
@@ -58,7 +61,6 @@ 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{
|
||||
@@ -68,15 +70,12 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
Health: state.health,
|
||||
},
|
||||
},
|
||||
}, nil).After(50 * time.Millisecond) // Simulate 50ms Docker API call
|
||||
|
||||
expected.Running += state.expected.Running
|
||||
expected.Stopped += state.expected.Stopped
|
||||
expected.Healthy += state.expected.Healthy
|
||||
expected.Unhealthy += state.expected.Unhealthy
|
||||
expected.Total++
|
||||
}, nil).After(30 * time.Millisecond) // Simulate 30ms 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)
|
||||
|
||||
// Call the function and measure time
|
||||
startTime := time.Now()
|
||||
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
|
||||
@@ -84,11 +83,10 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Assert results
|
||||
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, 6, stats.Running)
|
||||
assert.Equal(t, 4, stats.Stopped)
|
||||
assert.Equal(t, 2, stats.Healthy)
|
||||
assert.Equal(t, 2, stats.Unhealthy)
|
||||
assert.Equal(t, 10, stats.Total)
|
||||
|
||||
// Verify concurrent processing by checking that all mock calls were made
|
||||
|
||||
@@ -77,6 +77,9 @@ 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 {
|
||||
|
||||
@@ -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.Delete,
|
||||
"delete": client.DeleteDynamic,
|
||||
}
|
||||
|
||||
operationFunc, ok := operations[operation]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@@ -25,7 +26,7 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) {
|
||||
func (handler *Handler) authenticateOAuth(ctx context.Context, code string, settings *portainer.OAuthSettings) (string, error) {
|
||||
if code == "" {
|
||||
return "", errors.New("Invalid OAuth authorization code")
|
||||
}
|
||||
@@ -34,7 +35,7 @@ func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuth
|
||||
return "", errors.New("Invalid OAuth configuration")
|
||||
}
|
||||
|
||||
username, err := handler.OAuthService.Authenticate(code, settings)
|
||||
username, err := handler.OAuthService.Authenticate(ctx, code, settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -70,7 +71,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(payload.Code, &settings.OAuthSettings)
|
||||
username, err := handler.authenticateOAuth(r.Context(), payload.Code, &settings.OAuthSettings)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("OAuth authentication error")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -38,9 +39,9 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func deleteEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) error {
|
||||
_, 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)
|
||||
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)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
@@ -147,7 +148,9 @@ func (handler *Handler) updateEdgeSchedule(tx dataservices.DataStoreTx, edgeJob
|
||||
|
||||
if len(payload.EdgeGroups) > 0 {
|
||||
for _, edgeGroupID := range payload.EdgeGroups {
|
||||
if _, err := tx.EdgeGroup().Read(edgeGroupID); err != nil {
|
||||
if ok, err := tx.EdgeGroup().Exists(edgeGroupID); !ok {
|
||||
return dserrors.ErrObjectNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -42,9 +43,9 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
func (handler *Handler) removeEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error {
|
||||
_, 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)
|
||||
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)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
@@ -149,11 +149,9 @@ 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
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
err = tx.Tag().Update(tagID, tag)
|
||||
}
|
||||
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
if tx.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 := handler.DataStore.EdgeJob().ReadAll()
|
||||
edgeJobs, err := tx.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(handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
|
||||
if err := handler.PendingActionsService.Create(handler.DataStore, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to clean NAP with override policies")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.38.1
|
||||
// @version 2.39.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ 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
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -51,47 +52,52 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *
|
||||
return httperror.InternalServerError("Unable to remove the registry from the database", err)
|
||||
}
|
||||
|
||||
handler.deleteKubernetesSecrets(registry)
|
||||
handler.deleteKubernetesSecrets(handler.DataStore, registry)
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) {
|
||||
func (handler *Handler) deleteKubernetesSecrets(tx dataservices.DataStoreTx, registry *portainer.Registry) {
|
||||
for endpointId, access := range registry.RegistryAccesses {
|
||||
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)
|
||||
if access.Namespaces == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 len(failedNamespaces) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(payload.AutoUpdate.Webhook)
|
||||
isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, 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(payload.AutoUpdate.Webhook); err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, 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)
|
||||
|
||||
@@ -192,28 +192,23 @@ 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
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
|
||||
|
||||
isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
|
||||
if err != nil {
|
||||
if isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for name collision", err)
|
||||
}
|
||||
if !isUnique {
|
||||
} else if !isUnique {
|
||||
return stackExistsError(payload.Name)
|
||||
}
|
||||
|
||||
//make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
|
||||
if err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
}
|
||||
if !isUnique {
|
||||
} else if !isUnique {
|
||||
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,9 +206,9 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
|
||||
return isUniqueStackName, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) {
|
||||
_, err := handler.DataStore.Stack().StackByWebhookID(webhookID)
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
func (handler *Handler) checkUniqueWebhookID(tx dataservices.DataStoreTx, webhookID string) (bool, error) {
|
||||
_, err := tx.Stack().StackByWebhookID(webhookID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
|
||||
@@ -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(payload.AutoUpdate.Webhook); !isUnique || err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); !isUnique || err != nil {
|
||||
return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,7 +747,7 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht
|
||||
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseObject, response.StatusCode)
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
|
||||
+26
-10
@@ -3,8 +3,10 @@ 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"
|
||||
@@ -55,29 +57,43 @@ 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 {
|
||||
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
if err == nil {
|
||||
err = exec.StreamWithContext(context.TODO(), streamOpts)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("context", "StartExecProcess").
|
||||
Msg("WebSocket exec failed, falling back to SPDY")
|
||||
}
|
||||
|
||||
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
Tty: true,
|
||||
})
|
||||
// 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)
|
||||
if err != nil {
|
||||
var exitError utilexec.ExitError
|
||||
if !errors.As(err, &exitError) {
|
||||
errChan <- errors.New("unable to start exec process")
|
||||
errChan <- fmt.Errorf("unable to start exec process: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-14
@@ -30,8 +30,11 @@ 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(code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
token, err := GetOAuthToken(code, configuration)
|
||||
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)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed retrieving oauth token")
|
||||
|
||||
@@ -43,7 +46,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
|
||||
log.Error().Err(err).Msg("failed parsing id_token")
|
||||
}
|
||||
|
||||
resource, err := GetResource(token.AccessToken, configuration.ResourceURI)
|
||||
resource, err := GetResource(ctx, token.AccessToken, configuration.ResourceURI)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed retrieving resource")
|
||||
|
||||
@@ -62,7 +65,7 @@ func (Service) Authenticate(code string, configuration *portainer.OAuthSettings)
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
func GetOAuthToken(ctx context.Context, code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
unescapedCode, err := url.QueryUnescape(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -70,9 +73,6 @@ func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2
|
||||
|
||||
config := buildConfig(configuration)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
return config.Exchange(ctx, unescapedCode)
|
||||
}
|
||||
|
||||
@@ -87,9 +87,7 @@ 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 {
|
||||
@@ -103,16 +101,15 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func GetResource(token string, resourceURI string) (map[string]any, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, resourceURI, nil)
|
||||
func GetResource(ctx context.Context, token string, resourceURI string) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURI, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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(code, config); err == nil {
|
||||
if _, err := GetOAuthToken(t.Context(), 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(code, config)
|
||||
token, err := GetOAuthToken(t.Context(), 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("", config.ResourceURI); err == nil {
|
||||
if _, err := GetResource(t.Context(), "", 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("incorrect-token", config.ResourceURI); err == nil {
|
||||
if _, err := GetResource(t.Context(), "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(oauthtest.AccessToken, config.ResourceURI); err != nil {
|
||||
if _, err := GetResource(t.Context(), 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(code, config); err == nil {
|
||||
if _, err := authService.Authenticate(t.Context(), 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(code, config)
|
||||
username, err := authService.Authenticate(t.Context(), code, config)
|
||||
if err != nil {
|
||||
t.Errorf("Authenticate should succeed to extract username from resource if correct UserIdentifier provided; UserIdentifier=%s", config.UserIdentifier)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -20,38 +21,34 @@ 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,
|
||||
mu: sync.Mutex{},
|
||||
}
|
||||
func NewService(dataStore dataservices.DataStore, kubeFactory *kubecli.ClientFactory) *PendingActionsService {
|
||||
return &PendingActionsService{dataStore: dataStore, kubeFactory: kubeFactory}
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) RegisterHandler(name string, handler portainer.PendingActionHandler) {
|
||||
handlers[name] = handler
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) Create(action portainer.PendingAction) error {
|
||||
func (service *PendingActionsService) Create(tx dataservices.DataStoreTx, action portainer.PendingAction) error {
|
||||
// Check if this pendingAction already exists
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll()
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve pending actions: %w", err)
|
||||
}
|
||||
|
||||
for _, dba := range pendingActions {
|
||||
if len(pendingActions) > 0 {
|
||||
// Same endpoint, same action and data, don't create a repeat
|
||||
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
|
||||
}
|
||||
log.Debug().
|
||||
Str("action", action.Action).
|
||||
Int("endpoint_id", int(action.EndpointID)).
|
||||
Msg("pending action already exists for environment, skipping...")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return service.dataStore.PendingActions().Create(&action)
|
||||
return tx.PendingActions().Create(&action)
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) Execute(id portainer.EndpointID) {
|
||||
@@ -65,7 +62,8 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
|
||||
|
||||
endpoint, err := service.dataStore.Endpoint().Endpoint(environmentID)
|
||||
if err != nil {
|
||||
log.Debug().Msgf("failed to retrieve environment %d: %v", environmentID, err)
|
||||
log.Debug().Err(err).Int("endpoint_id", int(environmentID)).Msg("failed to retrieve environment")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,48 +84,55 @@ 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().Msgf("failed to create Kubernetes client for environment %d: %v", environmentID, err)
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environmentID)).
|
||||
Msg("failed to create Kubernetes client for environment")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = client.ServerVersion(); err != nil {
|
||||
log.Debug().Err(err).Msgf("Environment %q (id: %d) is not up", endpoint.Name, environmentID)
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Str("endpoint_name", endpoint.Name).
|
||||
Int("endpoint_id", int(environmentID)).
|
||||
Msg("environment is not up")
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll()
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
|
||||
return a.EndpointID == environmentID
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to read pending actions: %v", err)
|
||||
log.Warn().Err(err).Msg("failed to read pending actions")
|
||||
return
|
||||
}
|
||||
|
||||
if len(pendingActions) > 0 {
|
||||
log.Debug().Msgf("Found %d pending actions", len(pendingActions))
|
||||
log.Debug().Int("pending_action_count", len(pendingActions)).Msg("found pending actions")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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")
|
||||
|
||||
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)
|
||||
continue
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +145,8 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
|
||||
|
||||
handler, ok := handlers[pendingAction.Action]
|
||||
if !ok {
|
||||
log.Warn().Msgf("no handler found for pending action %s", pendingAction.Action)
|
||||
log.Warn().Str("action", pendingAction.Action).Msg("no handler found for pending action")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+26
-9
@@ -355,6 +355,20 @@ 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
|
||||
@@ -544,11 +558,14 @@ type (
|
||||
}
|
||||
|
||||
PolicyChartStatus struct {
|
||||
ChartName string `json:"chartName"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Status HelmInstallStatus `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Namespace string `json:"namespace"`
|
||||
// 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"`
|
||||
// Unix timestamp
|
||||
LastAttemptTime int64 `json:"lastAttemptTime"`
|
||||
}
|
||||
@@ -559,7 +576,7 @@ type (
|
||||
}
|
||||
|
||||
PolicyChartBundle struct {
|
||||
PolicyChartSummary
|
||||
PolicyChartSummary `mapstructure:",squash"`
|
||||
EncodedTgz string `json:"EncodedTgz"`
|
||||
Namespace string `json:"Namespace"`
|
||||
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
|
||||
@@ -1817,7 +1834,7 @@ type (
|
||||
|
||||
// OAuthService represents a service used to authenticate users using OAuth
|
||||
OAuthService interface {
|
||||
Authenticate(code string, configuration *OAuthSettings) (string, error)
|
||||
Authenticate(ctx context.Context, code string, configuration *OAuthSettings) (string, error)
|
||||
}
|
||||
|
||||
// ReverseTunnelService represents a service used to manage reverse tunnel connections.
|
||||
@@ -1857,9 +1874,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.38.1"
|
||||
APIVersion = "2.39.0"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
APIVersionSupport = "LTS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
|
||||
@@ -74,18 +74,10 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
|
||||
}
|
||||
}
|
||||
|
||||
if err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
|
||||
return 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 {
|
||||
|
||||
@@ -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: 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.widget .widget-header .pagination,
|
||||
.widget .widget-footer .pagination {
|
||||
@@ -103,7 +103,7 @@ div.input-mask {
|
||||
|
||||
.widget .widget-body {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.widget .widget-body table thead {
|
||||
background: var(--bg-widget-table-color);
|
||||
|
||||
+4
-7
@@ -61,11 +61,8 @@ angular
|
||||
.config(configApp);
|
||||
|
||||
if (require) {
|
||||
const req = require.context('./', true, /^(.*\.(js$))[^.]*$/im);
|
||||
req
|
||||
.keys()
|
||||
.filter((path) => !path.includes('.test'))
|
||||
.forEach(function (key) {
|
||||
req(key);
|
||||
});
|
||||
const req = require.context('./', true, /^(?!.*\.test\.js$).*\.js$/im);
|
||||
req.keys().forEach(function (key) {
|
||||
req(key);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,8 +195,7 @@ angular
|
||||
},
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: './views/endpoints/edit/endpoint.html',
|
||||
controller: 'EndpointController',
|
||||
component: 'environmentsItemView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,74 +1,10 @@
|
||||
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']))
|
||||
.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;
|
||||
.component('tagsDatatable', r2a(TagsDatatable, ['dataset', 'onRemove'])).name;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
@@ -7,8 +7,6 @@ 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';
|
||||
@@ -21,6 +19,7 @@ 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', [
|
||||
@@ -32,18 +31,12 @@ 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(
|
||||
@@ -58,10 +51,7 @@ export const viewsModule = angular
|
||||
['onSubmit', 'settings']
|
||||
)
|
||||
)
|
||||
.component(
|
||||
'environmentsListView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(EnvironmentsListView))), [])
|
||||
)
|
||||
|
||||
.component(
|
||||
'backupSettingsPanel',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(BackupSettingsPanel))), [])
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
<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>
|
||||
@@ -1,344 +0,0 @@
|
||||
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();
|
||||
}
|
||||
+36
-17
@@ -1,6 +1,6 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { render } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
@@ -17,31 +17,50 @@ vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
test('submit button should be disabled when name or image is missing', async () => {
|
||||
server.use(http.get('/api/endpoints/5', () => HttpResponse.json({})));
|
||||
|
||||
function renderComponent() {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withUserProvider(withTestRouter(CreateContainerInstanceForm), user)
|
||||
);
|
||||
const { findByText, getByText, getByLabelText } = render(<Wrapped />);
|
||||
|
||||
await expect(findByText(/Azure settings/)).resolves.toBeVisible();
|
||||
return render(<Wrapped />);
|
||||
}
|
||||
|
||||
const button = getByText(/Deploy the container/);
|
||||
expect(button).toBeVisible();
|
||||
expect(button).toBeDisabled();
|
||||
describe('CreateContainerInstanceForm', () => {
|
||||
beforeEach(() => {
|
||||
server.use(http.get('/api/endpoints/5', () => HttpResponse.json({})));
|
||||
});
|
||||
|
||||
const nameInput = getByLabelText(/name/i, { selector: 'input' });
|
||||
await userEvent.type(nameInput, 'name');
|
||||
// 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 imageInput = getByLabelText(/image/i, { selector: 'input' });
|
||||
await userEvent.type(imageInput, 'image');
|
||||
const errors = await screen.findByRole('alert');
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
|
||||
// Check that no error messages (role="alert") are visible
|
||||
expect(errors).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(nameInput).toHaveValue('name');
|
||||
await userEvent.clear(nameInput);
|
||||
it('submit button should be disabled when name or image is missing', async () => {
|
||||
const { findByText, getByText, getByLabelText } = renderComponent();
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { 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,
|
||||
@@ -61,161 +49,15 @@ export function CreateContainerInstanceForm({
|
||||
validateOnChange
|
||||
enableReinitialize
|
||||
>
|
||||
{({
|
||||
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>
|
||||
{(formikProps) => (
|
||||
<CreateContainerInstanceInnerForm
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...formikProps}
|
||||
subscriptionOptions={subscriptionOptions}
|
||||
environmentId={environmentId}
|
||||
resourceGroups={resourceGroups}
|
||||
providers={providers}
|
||||
/>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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,6 +79,7 @@ export type SwarmCreatePayload =
|
||||
git: GitFormModel;
|
||||
relativePathSettings?: RelativePathModel;
|
||||
fromAppTemplate?: boolean;
|
||||
webhook?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -108,6 +109,7 @@ type StandaloneCreatePayload =
|
||||
git: GitFormModel;
|
||||
relativePathSettings?: RelativePathModel;
|
||||
fromAppTemplate?: boolean;
|
||||
webhook?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -126,6 +128,7 @@ type KubernetesCreatePayload =
|
||||
payload: KubernetesBasePayload & {
|
||||
git: GitFormModel;
|
||||
relativePathSettings?: RelativePathModel;
|
||||
webhook?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -198,7 +201,10 @@ function createSwarmStack({ method, payload }: SwarmCreatePayload) {
|
||||
filesystemPath: payload.relativePathSettings?.FilesystemPath,
|
||||
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
|
||||
tlsSkipVerify: payload.git.TLSSkipVerify,
|
||||
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
|
||||
autoUpdate: transformAutoUpdateViewModel(
|
||||
payload.git.AutoUpdate,
|
||||
payload.webhook
|
||||
),
|
||||
environmentId: payload.environmentId,
|
||||
swarmID: payload.swarmId,
|
||||
additionalFiles: payload.git.AdditionalFiles,
|
||||
@@ -246,7 +252,10 @@ function createStandaloneStack({ method, payload }: StandaloneCreatePayload) {
|
||||
filesystemPath: payload.relativePathSettings?.FilesystemPath,
|
||||
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
|
||||
tlsSkipVerify: payload.git.TLSSkipVerify,
|
||||
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
|
||||
autoUpdate: transformAutoUpdateViewModel(
|
||||
payload.git.AutoUpdate,
|
||||
payload.webhook
|
||||
),
|
||||
environmentId: payload.environmentId,
|
||||
additionalFiles: payload.git.AdditionalFiles,
|
||||
fromAppTemplate: payload.fromAppTemplate,
|
||||
@@ -291,7 +300,10 @@ function createKubernetesStack({ method, payload }: KubernetesCreatePayload) {
|
||||
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
|
||||
|
||||
tlsSkipVerify: payload.git.TLSSkipVerify,
|
||||
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
|
||||
autoUpdate: transformAutoUpdateViewModel(
|
||||
payload.git.AutoUpdate,
|
||||
payload.webhook
|
||||
),
|
||||
environmentId: payload.environmentId,
|
||||
additionalFiles: payload.git.AdditionalFiles,
|
||||
composeFormat: payload.composeFormat,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
|
||||
import { Alert } from './Alert';
|
||||
|
||||
@@ -21,21 +21,21 @@ function Template({ text, color, title }: Args) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Success: Story<Args> = Template.bind({});
|
||||
export const Success: StoryFn<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: Story<Args> = Template.bind({});
|
||||
export const Error: StoryFn<Args> = Template.bind({});
|
||||
Error.args = {
|
||||
color: 'error',
|
||||
title: 'Error',
|
||||
text: 'This is an error alert',
|
||||
};
|
||||
|
||||
export const Info: Story<Args> = Template.bind({});
|
||||
export const Info: StoryFn<Args> = Template.bind({});
|
||||
Info.args = {
|
||||
color: 'info',
|
||||
title: 'Info',
|
||||
|
||||
@@ -96,7 +96,7 @@ export function AlertContainer({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'border rounded-lg border-solid [&_ul]:ps-8',
|
||||
'border rounded-xl border-solid [&_ul]:ps-8',
|
||||
'p-3',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } 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: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } 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: Story<Args> = Template.bind({});
|
||||
export const Primary: StoryFn<Args> = Template.bind({});
|
||||
Primary.args = {
|
||||
text: 'curl -X GET http://ultra-sound-money.eth',
|
||||
showCopyButton: true,
|
||||
};
|
||||
|
||||
export const MultiLine: Story<Args> = Template.bind({});
|
||||
export const MultiLine: StoryFn<Args> = Template.bind({});
|
||||
MultiLine.args = {
|
||||
text: 'curl -X\n GET http://example-with-children.crypto',
|
||||
};
|
||||
|
||||
export const MultiLineWithIcon: Story<Args> = Template.bind({});
|
||||
export const MultiLineWithIcon: StoryFn<Args> = Template.bind({});
|
||||
MultiLineWithIcon.args = {
|
||||
text: 'curl -X\n GET http://example-with-children.crypto',
|
||||
showCopyButton: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } 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: Story<StoryProps> = Template.bind({});
|
||||
export const Primary: StoryFn<StoryProps> = Template.bind({});
|
||||
Primary.args = {
|
||||
value: 1,
|
||||
icon: List,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } 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: Story<Args> = Template.bind({});
|
||||
export const Default: StoryFn<Args> = Template.bind({});
|
||||
Default.args = {
|
||||
key1: 'Name',
|
||||
val1: 'My Cool App',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Story, Meta } from '@storybook/react';
|
||||
import { StoryFn, 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: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {
|
||||
className: 'test-class',
|
||||
children: 'Loading...',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } 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: Story<Props> = Template.bind({});
|
||||
export const Primary: StoryFn<Props> = Template.bind({});
|
||||
Primary.args = {
|
||||
header: 'Insights box header',
|
||||
content: 'This is the content of the insights box',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ComponentMeta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } 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 ComponentMeta<typeof NavTabs>;
|
||||
} as Meta<typeof NavTabs>;
|
||||
|
||||
type Args = {
|
||||
options: Option[];
|
||||
@@ -26,7 +26,7 @@ function Template({ options = [] }: Args) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Example: Story<Args> = Template.bind({});
|
||||
export const Example: StoryFn<Args> = Template.bind({});
|
||||
Example.args = {
|
||||
options: [
|
||||
{ children: 'Content 1', id: 'option1', label: 'Option 1' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
@@ -39,7 +39,7 @@ function Template({ title }: StoryProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: Story<StoryProps> = Template.bind({});
|
||||
export const Primary: StoryFn<StoryProps> = Template.bind({});
|
||||
Primary.args = {
|
||||
title: 'Container details',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
@@ -37,7 +37,7 @@ function Template({ title }: StoryProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: Story<StoryProps> = Template.bind({});
|
||||
export const Primary: StoryFn<StoryProps> = Template.bind({});
|
||||
Primary.args = {
|
||||
title: 'Container details',
|
||||
};
|
||||
|
||||
@@ -66,7 +66,6 @@
|
||||
.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);
|
||||
|
||||
@@ -65,7 +65,7 @@ const SheetOverlay = forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={clsx(
|
||||
'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',
|
||||
'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',
|
||||
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 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',
|
||||
'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',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
|
||||
@@ -15,10 +15,10 @@ export function StickyFooter({
|
||||
<div
|
||||
className={clsx(
|
||||
styles.actionBar,
|
||||
'fixed bottom-0 right-0 z-50 h-16',
|
||||
'fixed bottom-0 right-0 z-40 h-16',
|
||||
'flex items-center px-6',
|
||||
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
|
||||
'shadow-[0_-2px_10px_rgba(0,0,0,0.1)]',
|
||||
'shadow-[0_-2px_5px_rgba(0,0,0,0.1)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { TextTip } from './TextTip';
|
||||
@@ -14,7 +14,7 @@ function Template({
|
||||
return <TextTip>{children}</TextTip>;
|
||||
}
|
||||
|
||||
export const Primary: Story<PropsWithChildren<unknown>> = Template.bind({});
|
||||
export const Primary: StoryFn<PropsWithChildren<unknown>> = Template.bind({});
|
||||
Primary.args = {
|
||||
children: 'This is a text tip with children',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
|
||||
import { Tooltip, Props } from './Tooltip';
|
||||
|
||||
@@ -16,7 +16,7 @@ function Template({ message, position }: JSX.IntrinsicAttributes & Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: Story<Props> = Template.bind({});
|
||||
export const Primary: StoryFn<Props> = Template.bind({});
|
||||
Primary.args = {
|
||||
message: 'Tooltip example',
|
||||
position: 'bottom',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
|
||||
import { ViewLoading } from './ViewLoading';
|
||||
|
||||
@@ -15,7 +15,7 @@ function Template({ message }: Args) {
|
||||
return <ViewLoading message={message} />;
|
||||
}
|
||||
|
||||
export const Example: Story<Args> = Template.bind({});
|
||||
export const Example: StoryFn<Args> = Template.bind({});
|
||||
Example.args = {
|
||||
message: 'Loading...',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -15,54 +15,76 @@ export interface Tab {
|
||||
interface Props {
|
||||
currentTabIndex: number;
|
||||
tabs: Tab[];
|
||||
useContainer?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
export function WidgetTabs({
|
||||
currentTabIndex,
|
||||
tabs,
|
||||
useContainer = true,
|
||||
ariaLabel = 'Section navigation',
|
||||
}: Props) {
|
||||
// ensure that the selectedTab param is always valid
|
||||
const invalidQueryParamValue = tabs.every(
|
||||
const invalidQueryParamValue = tabs.some(
|
||||
(tab) => encodeURIComponent(tab.selectedTabParam) !== tab.selectedTabParam
|
||||
);
|
||||
|
||||
if (invalidQueryParamValue) {
|
||||
throw new Error('Invalid query param value for tab');
|
||||
}
|
||||
|
||||
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>
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
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 }: { params: RawParams },
|
||||
tabs: Tab[]
|
||||
) {
|
||||
export function findSelectedTabIndex(params: RawParams, tabs: Tab[]) {
|
||||
const selectedTabParam = params.tab || tabs[0].selectedTabParam;
|
||||
const currentTabIndex = tabs.findIndex(
|
||||
(tab) => tab.selectedTabParam === selectedTabParam
|
||||
@@ -75,7 +97,7 @@ export function findSelectedTabIndex(
|
||||
|
||||
export function useCurrentTabIndex(tabs: Tab[]) {
|
||||
const params = useCurrentStateAndParams();
|
||||
const currentTabIndex = findSelectedTabIndex(params, tabs);
|
||||
const currentTabIndex = findSelectedTabIndex(params.params, tabs);
|
||||
|
||||
return currentTabIndex;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } 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: Story<Args> = Template.bind({});
|
||||
export const Primary: StoryFn<Args> = Template.bind({});
|
||||
Primary.args = {
|
||||
label: 'Create new container',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
@@ -85,7 +85,7 @@ function Template({
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {
|
||||
color: 'primary',
|
||||
size: 'small',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Play, RefreshCw, Square, Trash2 } from 'lucide-react';
|
||||
|
||||
@@ -45,7 +45,7 @@ function Template({
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {
|
||||
size: 'small',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { CopyButton, Props } from './CopyButton';
|
||||
@@ -24,13 +24,13 @@ function Template({
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {
|
||||
children: 'Copy',
|
||||
copyText: 'this will be copied to clipboard',
|
||||
};
|
||||
|
||||
export const NoCopyText: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const NoCopyText: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
NoCopyText.args = {
|
||||
children: 'Copy without copied text',
|
||||
copyText: 'clipboard override',
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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 { Button } from './Button';
|
||||
import { LoadingButton } from './LoadingButton';
|
||||
import { Button } from './Button';
|
||||
|
||||
type ConfirmOrClick =
|
||||
| {
|
||||
@@ -26,7 +27,10 @@ export function DeleteButton({
|
||||
size,
|
||||
children,
|
||||
isLoading,
|
||||
text = 'Remove',
|
||||
loadingText = 'Removing...',
|
||||
icon = false,
|
||||
type,
|
||||
'data-cy': dataCy,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
@@ -35,7 +39,10 @@ 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) {
|
||||
@@ -46,10 +53,11 @@ export function DeleteButton({
|
||||
disabled={disabled || isLoading}
|
||||
onClick={() => handleClick()}
|
||||
icon={Trash2}
|
||||
className="!m-0"
|
||||
className={clsx('!m-0', icon ? 'btn-icon' : '')}
|
||||
data-cy={dataCy}
|
||||
type={type}
|
||||
>
|
||||
{children || 'Remove'}
|
||||
{children || text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +73,7 @@ export function DeleteButton({
|
||||
data-cy={dataCy}
|
||||
isLoading={isLoading}
|
||||
loadingText={loadingText}
|
||||
type={type}
|
||||
>
|
||||
{children || 'Remove'}
|
||||
</LoadingButton>
|
||||
|
||||
@@ -7,31 +7,43 @@ 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) => (
|
||||
<td key={cell.id} className={getClassName(cell.column.columnDef.meta)}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</td>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function getClassName<D extends DefaultType = DefaultType>(
|
||||
function parseMeta<D extends DefaultType = DefaultType>(
|
||||
meta: ColumnMeta<D, unknown> | undefined
|
||||
) {
|
||||
return !!meta && 'className' in meta && typeof meta.className === 'string'
|
||||
? meta.className
|
||||
: '';
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
font-size: 16px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -100,8 +100,8 @@
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--border-datatable-top-color);
|
||||
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
||||
),
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
width: 50,
|
||||
width: '50px',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } 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: Story<Args> = Template.bind({});
|
||||
export const Example: StoryFn<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 */}
|
||||
{isExpanded && <div className="clear-both">{children}</div>}
|
||||
<div className="clear-both">{isExpanded && children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { FormSectionTitle } from './FormSectionTitle';
|
||||
@@ -14,7 +14,7 @@ function Template({
|
||||
return <FormSectionTitle>{children}</FormSectionTitle>;
|
||||
}
|
||||
|
||||
export const Example: Story<PropsWithChildren<unknown>> = Template.bind({});
|
||||
export const Example: StoryFn<PropsWithChildren<unknown>> = Template.bind({});
|
||||
Example.args = {
|
||||
children: 'This is a title with children',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Input } from './Input';
|
||||
@@ -27,7 +27,7 @@ export function TextField({ disabled }: Args) {
|
||||
);
|
||||
}
|
||||
|
||||
export const DisabledTextField: Story<Args> = TextField.bind({});
|
||||
export const DisabledTextField: StoryFn<Args> = TextField.bind({});
|
||||
DisabledTextField.args = {
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Select } from './Select';
|
||||
@@ -31,7 +31,7 @@ export function Example({ disabled }: Args) {
|
||||
);
|
||||
}
|
||||
|
||||
export const DisabledSelect: Story<Args> = Example.bind({});
|
||||
export const DisabledSelect: StoryFn<Args> = Example.bind({});
|
||||
DisabledSelect.args = {
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,6 @@ export function Select<T extends number | string>({
|
||||
options,
|
||||
className,
|
||||
'data-cy': dataCy,
|
||||
id,
|
||||
...props
|
||||
}: Props<T> & SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
|
||||
@@ -178,6 +178,10 @@
|
||||
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, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Slider, Props } from './Slider';
|
||||
@@ -41,7 +41,7 @@ function Template({
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: Story<Props> = Template.bind({});
|
||||
export const Primary: StoryFn<Props> = Template.bind({});
|
||||
Primary.args = {
|
||||
min: 0,
|
||||
max: 100,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Switch } from './Switch';
|
||||
@@ -40,12 +40,12 @@ function Template({ checked }: Args) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Checked: Story<Args> = Template.bind({});
|
||||
export const Checked: StoryFn<Args> = Template.bind({});
|
||||
Checked.args = {
|
||||
checked: true,
|
||||
};
|
||||
|
||||
export const Unchecked: Story<Args> = Template.bind({});
|
||||
export const Unchecked: StoryFn<Args> = Template.bind({});
|
||||
Unchecked.args = {
|
||||
checked: false,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { SwitchField } from './SwitchField';
|
||||
@@ -43,14 +43,14 @@ function Template({ checked, label, labelClass }: Args) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Checked: Story<Args> = Template.bind({});
|
||||
export const Checked: StoryFn<Args> = Template.bind({});
|
||||
Checked.args = {
|
||||
checked: true,
|
||||
label: 'label',
|
||||
labelClass: 'col-sm-6',
|
||||
};
|
||||
|
||||
export const Unchecked: Story<Args> = Template.bind({});
|
||||
export const Unchecked: StoryFn<Args> = Template.bind({});
|
||||
Unchecked.args = {
|
||||
checked: false,
|
||||
label: 'label',
|
||||
|
||||
@@ -37,11 +37,11 @@ export function Modal({
|
||||
<Context.Provider value>
|
||||
<DialogOverlay
|
||||
isOpen
|
||||
className={clsx(
|
||||
styles.overlay,
|
||||
'flex items-center justify-center z-50'
|
||||
)}
|
||||
className={clsx(styles.overlay, 'flex items-center justify-center')}
|
||||
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}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import { Values } from './useInitialValues';
|
||||
import { EnvVarsTab } from './EnvVarsTab';
|
||||
import { EditResourcesForm } from './ResourcesTab/EditResourceForm';
|
||||
|
||||
export function InnerForm({
|
||||
export function CreateInnerForm({
|
||||
hideCapabilities = false,
|
||||
isLoading,
|
||||
isDuplicate,
|
||||
@@ -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 { InnerForm } from './InnerForm';
|
||||
import { CreateInnerForm } from './CreateInnerForm';
|
||||
import { toRequest } from './toRequest';
|
||||
|
||||
export function CreateView() {
|
||||
@@ -49,9 +49,7 @@ function CreateForm() {
|
||||
const router = useRouter();
|
||||
const isWindows = useIsWindows(environmentId);
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
const { authorized: isEnvironmentAdmin } = useIsEnvironmentAdmin({
|
||||
adminOnlyCE: true,
|
||||
});
|
||||
const { authorized: isEnvironmentAdmin } = useIsEnvironmentAdmin();
|
||||
const [isDockerhubRateLimited, setIsDockerhubRateLimited] = useState(false);
|
||||
|
||||
const mutation = useCreateOrReplaceMutation();
|
||||
@@ -84,7 +82,6 @@ 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) ||
|
||||
@@ -122,7 +119,7 @@ function CreateForm() {
|
||||
validateOnMount
|
||||
validationSchema={validationSchema}
|
||||
>
|
||||
<InnerForm
|
||||
<CreateInnerForm
|
||||
hideCapabilities={hideCapabilities}
|
||||
onChangeName={syncName}
|
||||
isDuplicate={isDuplicating}
|
||||
|
||||
@@ -60,6 +60,7 @@ vi.mock('@/portainer/services/notifications', () => ({
|
||||
describe('ImportExportButtons', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
mockConfirmImageExport.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
@@ -379,9 +380,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,6 +31,15 @@ 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();
|
||||
|
||||
@@ -102,17 +111,15 @@ describe('CreateStackForm', () => {
|
||||
it('should show validation error for empty name', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/name is required/i)).toBeVisible();
|
||||
});
|
||||
expect(await screen.findByText(/name is required/i)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should show validation error for empty editor content', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/stack file content is required/i)).toBeVisible();
|
||||
});
|
||||
expect(
|
||||
await screen.findByText(/stack file content is required/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('should submit editor form successfully', async () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
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';
|
||||
@@ -13,7 +15,7 @@ import { getDefaultModel } from '@/react/portainer/gitops/types';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { useValidationSchema } from './useValidationSchema';
|
||||
import { InnerForm } from './InnerForm';
|
||||
import { CreateStackInnerForm } from './CreateStackInnerForm';
|
||||
|
||||
interface Props {
|
||||
environmentId: EnvironmentId;
|
||||
@@ -29,7 +31,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 = {
|
||||
@@ -52,7 +54,7 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) {
|
||||
variables: [],
|
||||
},
|
||||
env: [],
|
||||
webhookId: '',
|
||||
enableWebhook: false,
|
||||
registries: [],
|
||||
accessControl: defaultValues(isAdmin, user.Id),
|
||||
};
|
||||
@@ -64,10 +66,11 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) {
|
||||
validationSchema={validationSchema}
|
||||
validateOnMount
|
||||
>
|
||||
<InnerForm
|
||||
<CreateStackInnerForm
|
||||
isDeploying={createStackMutation.isLoading}
|
||||
isSwarm={isSwarm}
|
||||
isSaved={createStackMutation.isSuccess}
|
||||
webhookId={webhookId}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
@@ -75,12 +78,13 @@ 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
|
||||
);
|
||||
swarmId,
|
||||
webhookId,
|
||||
});
|
||||
|
||||
createStackMutation.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
@@ -91,12 +95,19 @@ export function CreateStackForm({ environmentId, isSwarm, swarmId }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildCreateStackPayload(
|
||||
values: FormValues,
|
||||
environmentId: EnvironmentId,
|
||||
stackType: 'swarm' | 'standalone',
|
||||
swarmId: string
|
||||
): CreateStackPayload {
|
||||
function buildCreateStackPayload({
|
||||
environmentId,
|
||||
stackType,
|
||||
swarmId,
|
||||
values,
|
||||
webhookId,
|
||||
}: {
|
||||
values: FormValues;
|
||||
environmentId: EnvironmentId;
|
||||
stackType: 'swarm' | 'standalone';
|
||||
swarmId: string;
|
||||
webhookId: string;
|
||||
}): CreateStackPayload {
|
||||
const basePayload = {
|
||||
name: values.name,
|
||||
environmentId,
|
||||
@@ -115,7 +126,7 @@ function buildCreateStackPayload(
|
||||
...basePayload,
|
||||
swarmId,
|
||||
fileContent: values.editor.fileContent,
|
||||
webhook: values.webhookId,
|
||||
webhook: values.enableWebhook ? webhookId : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -125,7 +136,7 @@ function buildCreateStackPayload(
|
||||
payload: {
|
||||
...basePayload,
|
||||
fileContent: values.editor.fileContent,
|
||||
webhook: values.webhookId,
|
||||
webhook: values.enableWebhook ? webhookId : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -141,7 +152,7 @@ function buildCreateStackPayload(
|
||||
...basePayload,
|
||||
swarmId,
|
||||
file: values.upload.file,
|
||||
webhook: values.webhookId,
|
||||
webhook: values.enableWebhook ? webhookId : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -151,7 +162,7 @@ function buildCreateStackPayload(
|
||||
payload: {
|
||||
...basePayload,
|
||||
file: values.upload.file,
|
||||
webhook: values.webhookId,
|
||||
webhook: values.enableWebhook ? webhookId : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -162,6 +173,7 @@ function buildCreateStackPayload(
|
||||
method: 'git',
|
||||
payload: {
|
||||
...basePayload,
|
||||
webhook: webhookId,
|
||||
swarmId,
|
||||
git: values.git,
|
||||
relativePathSettings: values.git.SupportRelativePath
|
||||
@@ -183,6 +195,7 @@ function buildCreateStackPayload(
|
||||
payload: {
|
||||
...basePayload,
|
||||
git: values.git,
|
||||
webhook: webhookId,
|
||||
relativePathSettings: values.git.SupportRelativePath
|
||||
? {
|
||||
SupportRelativePath: true,
|
||||
@@ -205,7 +218,7 @@ function buildCreateStackPayload(
|
||||
...basePayload,
|
||||
swarmId,
|
||||
fileContent: values.template.fileContent,
|
||||
webhook: values.webhookId,
|
||||
webhook: values.enableWebhook ? webhookId : undefined,
|
||||
fromAppTemplate: true,
|
||||
},
|
||||
};
|
||||
@@ -216,7 +229,7 @@ function buildCreateStackPayload(
|
||||
payload: {
|
||||
...basePayload,
|
||||
fileContent: values.template.fileContent,
|
||||
webhook: values.webhookId,
|
||||
webhook: values.enableWebhook ? webhookId : undefined,
|
||||
fromAppTemplate: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
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
Reference in New Issue
Block a user