Compare commits
12 Commits
community
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74913e842d | ||
|
|
420488116b | ||
|
|
bb3de163a6 | ||
|
|
3cef57760b | ||
|
|
7d49f61a05 | ||
|
|
0b51ad7f01 | ||
|
|
92527f1212 | ||
|
|
1c903b35a6 | ||
|
|
8a354ceceb | ||
|
|
7519e7cb89 | ||
|
|
a33a72923d | ||
|
|
8ddd2ade8b |
@@ -139,18 +139,15 @@ overrides:
|
||||
'react/jsx-props-no-spreading': off
|
||||
- files:
|
||||
- app/**/*.test.*
|
||||
plugins:
|
||||
- '@vitest'
|
||||
extends:
|
||||
- 'plugin:@vitest/legacy-recommended'
|
||||
- 'plugin:vitest/recommended'
|
||||
env:
|
||||
'@vitest/env': true
|
||||
'vitest/env': true
|
||||
rules:
|
||||
'react/jsx-no-constructed-context-values': off
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
'@vitest/no-conditional-expect': warn
|
||||
- files:
|
||||
- app/**/*.stories.*
|
||||
rules:
|
||||
@@ -158,4 +155,3 @@ overrides:
|
||||
'@typescript-eslint/no-restricted-imports': off
|
||||
no-restricted-imports: off
|
||||
'react/jsx-props-no-spreading': off
|
||||
'storybook/no-renderer-packages': off
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -94,13 +94,10 @@ body:
|
||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.38.1'
|
||||
- '2.38.0'
|
||||
- '2.37.0'
|
||||
- '2.36.0'
|
||||
- '2.35.0'
|
||||
- '2.34.0'
|
||||
- '2.33.7'
|
||||
- '2.33.6'
|
||||
- '2.33.5'
|
||||
- '2.33.4'
|
||||
|
||||
@@ -6,7 +6,7 @@ linters:
|
||||
settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|SSLSettings|Stack|Tag|User)$
|
||||
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
|
||||
msg: Use a transaction instead
|
||||
analyze-types: true
|
||||
exclusions:
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
dist
|
||||
api/datastore/test_data
|
||||
coverage
|
||||
api/datastore/test_data
|
||||
@@ -9,38 +9,20 @@ const config: StorybookConfig = {
|
||||
addons: [
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-essentials',
|
||||
'@storybook/addon-webpack5-compiler-swc',
|
||||
'@chromatic-com/storybook',
|
||||
{
|
||||
name: '@storybook/addon-styling-webpack',
|
||||
|
||||
name: '@storybook/addon-styling',
|
||||
options: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/,
|
||||
sideEffects: true,
|
||||
use: [
|
||||
require.resolve('style-loader'),
|
||||
{
|
||||
loader: require.resolve('css-loader'),
|
||||
options: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: require.resolve('postcss-loader'),
|
||||
options: {
|
||||
implementation: postcss,
|
||||
},
|
||||
},
|
||||
],
|
||||
cssLoaderOptions: {
|
||||
importLoaders: 1,
|
||||
modules: {
|
||||
localIdentName: '[path][name]__[local]',
|
||||
auto: true,
|
||||
exportLocalsConvention: 'camelCaseOnly',
|
||||
},
|
||||
],
|
||||
},
|
||||
postCss: {
|
||||
implementation: postcss,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import '../app/assets/css';
|
||||
import React from 'react';
|
||||
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
|
||||
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
|
||||
import { handlers } from '../app/setup-tests/server-handlers';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Preview } from '@storybook/react';
|
||||
|
||||
initMSW(
|
||||
{
|
||||
@@ -21,30 +21,31 @@ initMSW(
|
||||
handlers
|
||||
);
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
};
|
||||
|
||||
const testQueryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: (Story) => (
|
||||
export const decorators = [
|
||||
(Story) => (
|
||||
<QueryClientProvider client={testQueryClient}>
|
||||
<UIRouter plugins={[pushStateLocationPlugin]}>
|
||||
<Story />
|
||||
</UIRouter>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
loaders: [mswLoader],
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
msw: {
|
||||
handlers,
|
||||
},
|
||||
},
|
||||
};
|
||||
];
|
||||
|
||||
export default preview;
|
||||
export const loaders = [mswLoader];
|
||||
|
||||
44
CLAUDE.md
44
CLAUDE.md
@@ -1,44 +0,0 @@
|
||||
# Portainer Community Edition
|
||||
|
||||
Open-source container management platform with full Docker and Kubernetes support.
|
||||
|
||||
see also:
|
||||
|
||||
- docs/guidelines/server-architecture.md
|
||||
- docs/guidelines/go-conventions.md
|
||||
- docs/guidelines/typescript-conventions.md
|
||||
|
||||
## Package Manager
|
||||
|
||||
- **PNPM** 10+ (for frontend)
|
||||
- **Go** 1.25.7 (for backend)
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Full build
|
||||
make build # Build both client and server
|
||||
make build-client # Build React/AngularJS frontend
|
||||
make build-server # Build Go binary
|
||||
make build-image # Build Docker image
|
||||
|
||||
# Development
|
||||
make dev # Run both in dev mode
|
||||
make dev-client # Start webpack-dev-server (port 8999)
|
||||
make dev-server # Run containerized Go server
|
||||
|
||||
pnpm run dev # Webpack dev server
|
||||
pnpm run build # Build frontend with webpack
|
||||
pnpm run test # Run frontend tests
|
||||
|
||||
# Testing
|
||||
make test # All tests (backend + frontend)
|
||||
make test-server # Backend tests only
|
||||
make lint # Lint all code
|
||||
make format # Format code
|
||||
```
|
||||
|
||||
## Development Servers
|
||||
|
||||
- Frontend: http://localhost:8999
|
||||
- Backend: http://localhost:9000 (HTTP) / https://localhost:9443 (HTTPS)
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
)
|
||||
|
||||
@@ -109,7 +108,7 @@ func ExtractTarGz(r io.Reader, outputDirPath string) error {
|
||||
case tar.TypeDir:
|
||||
// skip, dir will be created with a file
|
||||
case tar.TypeReg:
|
||||
p := filesystem.JoinPaths(outputDirPath, header.Name)
|
||||
p := filepath.Clean(filepath.Join(outputDirPath, header.Name))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o744); err != nil {
|
||||
return fmt.Errorf("Failed to extract dir %s", filepath.Dir(p))
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -111,56 +108,3 @@ func Test_shouldCreateArchive2(t *testing.T) {
|
||||
wasExtracted("dir/inner")
|
||||
wasExtracted("dir/.dotfile")
|
||||
}
|
||||
|
||||
func TestExtractTarGzPathTraversal(t *testing.T) {
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Create an evil file with a path traversal attempt
|
||||
tarPath := filesystem.JoinPaths(testDir, "evil.tar.gz")
|
||||
|
||||
evilFile, err := os.Create(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
gzWriter := gzip.NewWriter(evilFile)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
content := []byte("evil content")
|
||||
|
||||
header := &tar.Header{
|
||||
Name: "../evil.txt",
|
||||
Mode: 0600,
|
||||
Size: int64(len(content)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
|
||||
err = tarWriter.WriteHeader(header)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tarWriter.Write(content)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = tarWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = gzWriter.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = evilFile.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Attempt to extract the evil file
|
||||
extractionDir := filesystem.JoinPaths(testDir, "extraction")
|
||||
err = os.Mkdir(extractionDir, 0700)
|
||||
require.NoError(t, err)
|
||||
|
||||
tarFile, err := os.Open(tarPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that the file didn't escape
|
||||
err = ExtractTarGz(tarFile, extractionDir)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, filesystem.JoinPaths(testDir, "evil.txt"))
|
||||
|
||||
err = tarFile.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -92,9 +92,7 @@ func CreateTLSConfigurationFromDisk(config portainer.TLSConfiguration) (*tls.Con
|
||||
}
|
||||
|
||||
func createTLSConfigurationFromDisk(fipsEnabled bool, config portainer.TLSConfiguration) (*tls.Config, error) { //nolint:forbidigo
|
||||
if !config.TLS && fipsEnabled {
|
||||
return nil, fips.ErrTLSRequired
|
||||
} else if !config.TLS {
|
||||
if !config.TLS {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, object); err != nil {
|
||||
if e := json.Unmarshal(data, object); e != nil {
|
||||
// Special case for the VERSION bucket. Here we're not using json
|
||||
// So we need to return it as a string
|
||||
s, ok := object.(*string)
|
||||
if !ok {
|
||||
return errors.Wrap(err, "Failed unmarshalling object")
|
||||
return errors.Wrap(err, e.Error())
|
||||
}
|
||||
|
||||
*s = string(data)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
var (
|
||||
ErrObjectNotFound = errors.New("object not found inside the database")
|
||||
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
|
||||
ErrWrongDBEdition = errors.New("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
ErrDBImportFailed = errors.New("importing backup failed")
|
||||
ErrDatabaseIsUpdating = errors.New("database is currently in updating state. Failed prior upgrade. Please restore from backup or delete the database and restart Portainer")
|
||||
)
|
||||
|
||||
@@ -31,13 +31,6 @@ func NewService(connection portainer.Connection) (*Service, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
return ServiceTx{
|
||||
service: service,
|
||||
tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
// Settings retrieve the ssl settings object.
|
||||
func (service *Service) Settings() (*portainer.SSLSettings, error) {
|
||||
var settings portainer.SSLSettings
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package ssl
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type ServiceTx struct {
|
||||
service *Service
|
||||
tx portainer.Transaction
|
||||
}
|
||||
|
||||
func (service ServiceTx) BucketName() string {
|
||||
return BucketName
|
||||
}
|
||||
|
||||
// Settings retrieve the settings object.
|
||||
func (service ServiceTx) Settings() (*portainer.SSLSettings, error) {
|
||||
var settings portainer.SSLSettings
|
||||
|
||||
err := service.tx.GetObject(BucketName, []byte(key), &settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// UpdateSettings persists a Settings object.
|
||||
func (service ServiceTx) UpdateSettings(settings *portainer.SSLSettings) error {
|
||||
return service.tx.UpdateObject(BucketName, []byte(key), settings)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func (m *Migrator) NeedsMigration() bool {
|
||||
|
||||
// In this particular instance we should log a fatal error
|
||||
if m.CurrentDBEdition() != portainer.PortainerCE {
|
||||
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://docs.portainer.io/faqs/upgrading/can-i-downgrade-from-portainer-business-to-portainer-ce")
|
||||
log.Fatal().Msg("the Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -74,9 +74,7 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
|
||||
return tx.store.SnapshotService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService {
|
||||
return tx.store.SSLSettingsService.Tx(tx.tx)
|
||||
}
|
||||
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
|
||||
|
||||
func (tx *StoreTx) Stack() dataservices.StackService {
|
||||
return tx.store.StackService.Tx(tx.tx)
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
"allowDeviceMappingForRegularUsers": true,
|
||||
"allowHostNamespaceForRegularUsers": true,
|
||||
"allowPrivilegedModeForRegularUsers": true,
|
||||
"allowSecurityOptForRegularUsers": false,
|
||||
"allowStackManagementForRegularUsers": true,
|
||||
"allowSysctlSettingForRegularUsers": false,
|
||||
"allowVolumeBrowserForRegularUsers": false,
|
||||
@@ -614,7 +613,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.39.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.38.1",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +942,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.39.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.38.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
@@ -36,10 +35,8 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
var aggErr error
|
||||
var aggMu sync.Mutex
|
||||
|
||||
var processedCount int
|
||||
for i := range containers {
|
||||
id := containers[i].ID
|
||||
|
||||
semaphore <- struct{}{}
|
||||
wg.Go(func() {
|
||||
defer func() { <-semaphore }()
|
||||
@@ -47,17 +44,8 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
containerInspection, err := cli.ContainerInspect(ctx, id)
|
||||
stat := ContainerStats{}
|
||||
if err != nil {
|
||||
if errdefs.IsNotFound(err) {
|
||||
// An edge case is reported that Docker can list containers with no names,
|
||||
// but when inspecting a container with specific ID and it is not found.
|
||||
// In this case, we can safely ignore the error.
|
||||
// ref@https://linear.app/portainer/issue/BE-12567/500-error-when-loading-docker-dashboard-in-portainer
|
||||
return
|
||||
}
|
||||
|
||||
aggMu.Lock()
|
||||
aggErr = errors.Join(aggErr, err)
|
||||
processedCount++
|
||||
aggMu.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -68,7 +56,6 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
stopped += stat.Stopped
|
||||
healthy += stat.Healthy
|
||||
unhealthy += stat.Unhealthy
|
||||
processedCount++
|
||||
mu.Unlock()
|
||||
})
|
||||
}
|
||||
@@ -80,7 +67,7 @@ func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool
|
||||
Stopped: stopped,
|
||||
Healthy: healthy,
|
||||
Unhealthy: unhealthy,
|
||||
Total: processedCount,
|
||||
Total: len(containers),
|
||||
}, aggErr
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@ package stats
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/containerd/containerd/errdefs"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -39,7 +37,6 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
{ID: "container8"},
|
||||
{ID: "container9"},
|
||||
{ID: "container10"},
|
||||
{ID: "container11"},
|
||||
}
|
||||
|
||||
// Setup mock expectations with different container states to test various scenarios
|
||||
@@ -61,6 +58,7 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
{"container10", container.StateDead, nil, ContainerStats{Running: 0, Stopped: 1, Healthy: 0, Unhealthy: 0}},
|
||||
}
|
||||
|
||||
expected := ContainerStats{}
|
||||
// Setup mock expectations for all containers with artificial delays to simulate real Docker calls
|
||||
for _, state := range containerStates {
|
||||
mockClient.On("ContainerInspect", mock.Anything, state.id).Return(container.InspectResponse{
|
||||
@@ -70,11 +68,14 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
Health: state.health,
|
||||
},
|
||||
},
|
||||
}, nil).After(30 * time.Millisecond) // Simulate 30ms Docker API call
|
||||
}
|
||||
}, nil).After(50 * time.Millisecond) // Simulate 50ms Docker API call
|
||||
|
||||
// Setup mock expectation for a container that returns NotFound error
|
||||
mockClient.On("ContainerInspect", mock.Anything, "container11").Return(container.InspectResponse{}, fmt.Errorf("No such container: %w", errdefs.ErrNotFound)).After(50 * time.Millisecond)
|
||||
expected.Running += state.expected.Running
|
||||
expected.Stopped += state.expected.Stopped
|
||||
expected.Healthy += state.expected.Healthy
|
||||
expected.Unhealthy += state.expected.Unhealthy
|
||||
expected.Total++
|
||||
}
|
||||
|
||||
// Call the function and measure time
|
||||
startTime := time.Now()
|
||||
@@ -83,10 +84,11 @@ func TestCalculateContainerStats(t *testing.T) {
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Assert results
|
||||
assert.Equal(t, 6, stats.Running)
|
||||
assert.Equal(t, 4, stats.Stopped)
|
||||
assert.Equal(t, 2, stats.Healthy)
|
||||
assert.Equal(t, 2, stats.Unhealthy)
|
||||
assert.Equal(t, expected, stats)
|
||||
assert.Equal(t, expected.Running, stats.Running)
|
||||
assert.Equal(t, expected.Stopped, stats.Stopped)
|
||||
assert.Equal(t, expected.Healthy, stats.Healthy)
|
||||
assert.Equal(t, expected.Unhealthy, stats.Unhealthy)
|
||||
assert.Equal(t, 10, stats.Total)
|
||||
|
||||
// Verify concurrent processing by checking that all mock calls were made
|
||||
|
||||
@@ -77,9 +77,6 @@ type (
|
||||
// CreatedByUserId is the user ID that created this stack
|
||||
// Used for adding labels to Kubernetes manifests
|
||||
CreatedByUserId string
|
||||
|
||||
// HelmConfig represents the Helm configuration for an edge stack
|
||||
HelmConfig portainer.HelmConfig
|
||||
}
|
||||
|
||||
DeployerOptionsPayload struct {
|
||||
|
||||
@@ -112,7 +112,7 @@ func (deployer *KubernetesDeployer) command(operation string, userID portainer.U
|
||||
|
||||
operations := map[string]func(context.Context, []string) (string, error){
|
||||
"apply": client.ApplyDynamic,
|
||||
"delete": client.DeleteDynamic,
|
||||
"delete": client.Delete,
|
||||
}
|
||||
|
||||
operationFunc, ok := operations[operation]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@@ -26,7 +25,7 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) authenticateOAuth(ctx context.Context, code string, settings *portainer.OAuthSettings) (string, error) {
|
||||
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) {
|
||||
if code == "" {
|
||||
return "", errors.New("Invalid OAuth authorization code")
|
||||
}
|
||||
@@ -35,7 +34,7 @@ func (handler *Handler) authenticateOAuth(ctx context.Context, code string, sett
|
||||
return "", errors.New("Invalid OAuth configuration")
|
||||
}
|
||||
|
||||
username, err := handler.OAuthService.Authenticate(ctx, code, settings)
|
||||
username, err := handler.OAuthService.Authenticate(code, settings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -71,7 +70,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
|
||||
return httperror.Forbidden("OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled"))
|
||||
}
|
||||
|
||||
username, err := handler.authenticateOAuth(r.Context(), payload.Code, &settings.OAuthSettings)
|
||||
username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("OAuth authentication error")
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -39,9 +38,9 @@ func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
func deleteEdgeGroup(tx dataservices.DataStoreTx, ID portainer.EdgeGroupID) error {
|
||||
ok, err := tx.EdgeGroup().Exists(ID)
|
||||
if !ok {
|
||||
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
|
||||
_, err := tx.EdgeGroup().Read(ID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/edge/cache"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -148,9 +147,7 @@ func (handler *Handler) updateEdgeSchedule(tx dataservices.DataStoreTx, edgeJob
|
||||
|
||||
if len(payload.EdgeGroups) > 0 {
|
||||
for _, edgeGroupID := range payload.EdgeGroups {
|
||||
if ok, err := tx.EdgeGroup().Exists(edgeGroupID); !ok {
|
||||
return dserrors.ErrObjectNotFound
|
||||
} else if err != nil {
|
||||
if _, err := tx.EdgeGroup().Read(edgeGroupID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dserrors "github.com/portainer/portainer/api/dataservices/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -43,9 +42,9 @@ func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
func (handler *Handler) removeEndpoint(tx dataservices.DataStoreTx, endpointGroupID portainer.EndpointGroupID, endpointID portainer.EndpointID) error {
|
||||
ok, err := tx.EndpointGroup().Exists(endpointGroupID)
|
||||
if !ok {
|
||||
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", dserrors.ErrObjectNotFound)
|
||||
_, err := tx.EndpointGroup().Read(endpointGroupID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an environment group with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an environment group with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
@@ -149,9 +149,11 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
if endpoint.GroupID == endpointGroup.ID && endpointutils.IsKubernetesEndpoint(&endpoint) {
|
||||
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(tx, &endpoint, endpointGroup); err != nil {
|
||||
// Update flag with endpoint and continue
|
||||
if err := handler.PendingActionsService.Create(tx, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, &endpointGroup.ID)); err != nil {
|
||||
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpoint.ID, endpointGroup.ID)
|
||||
}
|
||||
go func(endpointID portainer.EndpointID, endpointGroupID portainer.EndpointGroupID) {
|
||||
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpointID, &endpointGroupID)); err != nil {
|
||||
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID)
|
||||
}
|
||||
}(endpoint.ID, endpointGroup.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
err = tx.Tag().Update(tagID, tag)
|
||||
}
|
||||
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
log.Warn().Err(err).Msg("Unable to find tag inside the database")
|
||||
} else if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to delete tag relation from the database")
|
||||
@@ -221,7 +221,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
}
|
||||
|
||||
if endpointutils.IsEdgeEndpoint(endpoint) {
|
||||
edgeJobs, err := tx.EdgeJob().ReadAll()
|
||||
edgeJobs, err := handler.DataStore.EdgeJob().ReadAll()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to retrieve edge jobs from the database")
|
||||
}
|
||||
|
||||
@@ -26,8 +26,6 @@ type endpointSettingsUpdatePayload struct {
|
||||
AllowContainerCapabilitiesForRegularUsers *bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use sysctl settings
|
||||
AllowSysctlSettingForRegularUsers *bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use security-opt settings
|
||||
AllowSecurityOptForRegularUsers *bool `json:"allowSecurityOptForRegularUsers" example:"true"`
|
||||
// Whether host management features are enabled
|
||||
EnableHostManagementFeatures *bool `json:"enableHostManagementFeatures" example:"true"`
|
||||
|
||||
@@ -109,10 +107,6 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re
|
||||
securitySettings.AllowSysctlSettingForRegularUsers = *payload.AllowSysctlSettingForRegularUsers
|
||||
}
|
||||
|
||||
if payload.AllowSecurityOptForRegularUsers != nil {
|
||||
securitySettings.AllowSecurityOptForRegularUsers = *payload.AllowSecurityOptForRegularUsers
|
||||
}
|
||||
|
||||
if payload.EnableHostManagementFeatures != nil {
|
||||
securitySettings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures
|
||||
}
|
||||
|
||||
@@ -265,7 +265,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
if err := handler.AuthorizationService.CleanNAPWithOverridePolicies(handler.DataStore, endpoint, nil); err != nil {
|
||||
log.Warn().Err(err).Msgf("Unable to clean NAP with override policies for endpoint (%d). Will try to update when endpoint is online.", endpoint.ID)
|
||||
|
||||
if err := handler.PendingActionsService.Create(handler.DataStore, handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
|
||||
if err := handler.PendingActionsService.Create(handlers.NewCleanNAPWithOverridePolicies(endpoint.ID, nil)); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to clean NAP with override policies")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.39.0
|
||||
// @version 2.38.1
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ func (p *installChartPayload) Validate(_ *http.Request) error {
|
||||
return fmt.Errorf("required field(s) missing: %s", strings.Join(required, ", "))
|
||||
}
|
||||
|
||||
if err := validation.IsDNS1123Subdomain(p.Name); err != nil {
|
||||
if errs := validation.IsDNS1123Subdomain(p.Name); len(errs) > 0 {
|
||||
return errChartNameInvalid
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,6 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusForbidden, "an error occurred during the KubeClientMiddleware operation, permission denied to access the environment. Error: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
@@ -52,52 +51,47 @@ func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *
|
||||
return httperror.InternalServerError("Unable to remove the registry from the database", err)
|
||||
}
|
||||
|
||||
handler.deleteKubernetesSecrets(handler.DataStore, registry)
|
||||
handler.deleteKubernetesSecrets(registry)
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteKubernetesSecrets(tx dataservices.DataStoreTx, registry *portainer.Registry) {
|
||||
func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) {
|
||||
for endpointId, access := range registry.RegistryAccesses {
|
||||
if access.Namespaces == nil {
|
||||
continue
|
||||
}
|
||||
if access.Namespaces != nil {
|
||||
// Obtain a kubeclient for the endpoint
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointId)
|
||||
if err != nil {
|
||||
// Skip environments that can't be loaded from the DB
|
||||
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
|
||||
|
||||
// Obtain a kubeclient for the endpoint
|
||||
endpoint, err := tx.Endpoint().Endpoint(endpointId)
|
||||
if err != nil {
|
||||
// Skip environments that can't be loaded from the DB
|
||||
log.Warn().Err(err).Msgf("Unable to load the environment with id %d from the database", endpointId)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
// Skip environments that can't get a kubeclient from
|
||||
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
failedNamespaces := make([]string, 0)
|
||||
|
||||
for _, ns := range access.Namespaces {
|
||||
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
|
||||
failedNamespaces = append(failedNamespaces, ns)
|
||||
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(failedNamespaces) == 0 {
|
||||
continue
|
||||
}
|
||||
cli, err := handler.K8sClientFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
// Skip environments that can't get a kubeclient from
|
||||
log.Warn().Err(err).Msgf("Unable to get kubernetes client for environment %d", endpointId)
|
||||
|
||||
if err := handler.PendingActionsService.Create(
|
||||
tx,
|
||||
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
|
||||
); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
|
||||
continue
|
||||
}
|
||||
|
||||
failedNamespaces := make([]string, 0)
|
||||
|
||||
for _, ns := range access.Namespaces {
|
||||
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
|
||||
failedNamespaces = append(failedNamespaces, ns)
|
||||
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
|
||||
}
|
||||
}
|
||||
|
||||
if len(failedNamespaces) > 0 {
|
||||
if err := handler.PendingActionsService.Create(
|
||||
handlers.NewDeleteK8sRegistrySecrets(endpointId, registry.ID, failedNamespaces),
|
||||
); err != nil {
|
||||
log.Warn().Err(err).Msg("unable to schedule pending action to delete kubernetes registry secrets")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
|
||||
//make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook)
|
||||
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
|
||||
// Make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
} else if !isUnique {
|
||||
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
|
||||
|
||||
@@ -192,23 +192,28 @@ func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference
|
||||
// @router /stacks/create/swarm/repository [post]
|
||||
func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
|
||||
var payload swarmStackFromGitRepositoryPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
|
||||
|
||||
if isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true); err != nil {
|
||||
isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, payload.Name, 0, true)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to check for name collision", err)
|
||||
} else if !isUnique {
|
||||
}
|
||||
if !isUnique {
|
||||
return stackExistsError(payload.Name)
|
||||
}
|
||||
|
||||
//make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); err != nil {
|
||||
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to check for webhook ID collision", err)
|
||||
} else if !isUnique {
|
||||
}
|
||||
if !isUnique {
|
||||
return httperror.Conflict(fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), stackutils.ErrWebhookIDAlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,9 +206,9 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
|
||||
return isUniqueStackName, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) checkUniqueWebhookID(tx dataservices.DataStoreTx, webhookID string) (bool, error) {
|
||||
_, err := tx.Stack().StackByWebhookID(webhookID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) {
|
||||
_, err := handler.DataStore.Stack().StackByWebhookID(webhookID)
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
|
||||
@@ -2,7 +2,6 @@ package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -17,6 +16,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -215,7 +215,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to remove kubernetes resources: %q: %w", out, err)
|
||||
return errors.WithMessagef(err, "failed to remove kubernetes resources: %q", out)
|
||||
}
|
||||
|
||||
return fmt.Errorf("unsupported stack type: %v", stack.Type)
|
||||
@@ -315,7 +315,7 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
|
||||
|
||||
log.Debug().Msgf("Trying to delete Kubernetes stacks `%v` for endpoint `%d`", stacksToDelete, endpointID)
|
||||
|
||||
var errs error
|
||||
errors := make([]error, 0)
|
||||
// Delete all the stacks one by one
|
||||
for _, stack := range stacksToDelete {
|
||||
log.Debug().Msgf("Trying to delete Kubernetes stack id `%d`", stack.ID)
|
||||
@@ -328,27 +328,27 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
|
||||
err = handler.deleteStack(securityContext.UserID, &stack, endpoint)
|
||||
if err != nil {
|
||||
log.Err(err).Msgf("Unable to delete Kubernetes stack `%d`", stack.ID)
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := handler.DataStore.Stack().Delete(stack.ID); err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
log.Err(err).Msgf("Unable to remove the stack `%d` from the database", stack.ID)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := handler.FileService.RemoveDirectory(stack.ProjectPath); err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
log.Warn().Err(err).Msg("Unable to remove stack files from disk")
|
||||
}
|
||||
|
||||
log.Debug().Msgf("Kubernetes stack `%d` deleted", stack.ID)
|
||||
}
|
||||
|
||||
if errs != nil {
|
||||
if len(errors) > 0 {
|
||||
return httperror.InternalServerError("Unable to delete some Kubernetes stack(s). Check Portainer logs for more details", nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
|
||||
(stack.AutoUpdate == nil ||
|
||||
(stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(handler.DataStore, payload.AutoUpdate.Webhook); !isUnique || err != nil {
|
||||
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); !isUnique || err != nil {
|
||||
return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ var (
|
||||
ErrPIDHostNamespaceForbidden = errors.New("forbidden to use pid host namespace")
|
||||
ErrDeviceMappingForbidden = errors.New("forbidden to use device mapping")
|
||||
ErrSysCtlSettingsForbidden = errors.New("forbidden to use sysctl settings")
|
||||
ErrSecurityOptSettingsForbidden = errors.New("forbidden to use security-opt settings")
|
||||
ErrContainerCapabilitiesForbidden = errors.New("forbidden to use container capabilities")
|
||||
ErrBindMountsForbidden = errors.New("forbidden to use bind mounts")
|
||||
)
|
||||
@@ -171,14 +170,13 @@ func containerHasBlackListedLabel(containerLabels map[string]any, labelBlackList
|
||||
func (transport *Transport) decorateContainerCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
type PartialContainer struct {
|
||||
HostConfig struct {
|
||||
Privileged bool `json:"Privileged"`
|
||||
PidMode string `json:"PidMode"`
|
||||
Devices []any `json:"Devices"`
|
||||
Sysctls map[string]any `json:"Sysctls"`
|
||||
SecurityOpt []string `json:"SecurityOpt"`
|
||||
CapAdd []string `json:"CapAdd"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
Binds []string `json:"Binds"`
|
||||
Privileged bool `json:"Privileged"`
|
||||
PidMode string `json:"PidMode"`
|
||||
Devices []any `json:"Devices"`
|
||||
Sysctls map[string]any `json:"Sysctls"`
|
||||
CapAdd []string `json:"CapAdd"`
|
||||
CapDrop []string `json:"CapDrop"`
|
||||
Binds []string `json:"Binds"`
|
||||
} `json:"HostConfig"`
|
||||
}
|
||||
|
||||
@@ -228,10 +226,6 @@ func (transport *Transport) decorateContainerCreationOperation(request *http.Req
|
||||
return forbiddenResponse, ErrSysCtlSettingsForbidden
|
||||
}
|
||||
|
||||
if !securitySettings.AllowSecurityOptForRegularUsers && len(partialContainer.HostConfig.SecurityOpt) > 0 {
|
||||
return forbiddenResponse, ErrSecurityOptSettingsForbidden
|
||||
}
|
||||
|
||||
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(partialContainer.HostConfig.CapAdd) > 0 || len(partialContainer.HostConfig.CapDrop) > 0) {
|
||||
return nil, ErrContainerCapabilitiesForbidden
|
||||
}
|
||||
|
||||
@@ -747,7 +747,7 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht
|
||||
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
|
||||
return utils.RewriteResponse(response, responseObject, response.StatusCode)
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
|
||||
22
api/internal/errorlist/errorlist.go
Normal file
22
api/internal/errorlist/errorlist.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package errorlist
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Combine a slice of errors into a single error
|
||||
// to use this, generate errors by appending to errorList in a loop, then return combine(errorList)
|
||||
func Combine(errorList []error) error {
|
||||
if len(errorList) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var errorMsg strings.Builder
|
||||
_, _ = errorMsg.WriteString("Multiple errors occurred:")
|
||||
for _, err := range errorList {
|
||||
_, _ = errorMsg.WriteString("\n" + err.Error())
|
||||
}
|
||||
|
||||
return errors.New(errorMsg.String())
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -49,7 +50,7 @@ func parseClusterRole(clusterRole rbacv1.ClusterRole) models.K8sClusterRole {
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error {
|
||||
var errs error
|
||||
var errors []error
|
||||
for _, name := range req {
|
||||
client := kcl.cli.RbacV1().ClusterRoles()
|
||||
|
||||
@@ -69,11 +70,11 @@ func (kcl *KubeClient) DeleteClusterRoles(req models.K8sClusterRoleDeleteRequest
|
||||
err = client.Delete(context.Background(), name, meta.DeleteOptions{})
|
||||
if err != nil {
|
||||
log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role")
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
return errorlist.Combine(errors)
|
||||
}
|
||||
|
||||
func isSystemClusterRole(role *rbacv1.ClusterRole) bool {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -54,7 +55,7 @@ func parseClusterRoleBinding(clusterRoleBinding rbacv1.ClusterRoleBinding) model
|
||||
// by deleting each cluster role binding in its given namespace. If deleting a specific cluster role binding
|
||||
// fails, the error is logged and we continue to delete the remaining cluster role bindings.
|
||||
func (kcl *KubeClient) DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error {
|
||||
var errs error
|
||||
var errors []error
|
||||
|
||||
for _, name := range reqs {
|
||||
client := kcl.cli.RbacV1().ClusterRoleBindings()
|
||||
@@ -75,11 +76,11 @@ func (kcl *KubeClient) DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindi
|
||||
|
||||
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
|
||||
log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role binding")
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
return errorlist.Combine(errors)
|
||||
}
|
||||
|
||||
func isSystemClusterRoleBinding(binding *rbacv1.ClusterRoleBinding) bool {
|
||||
|
||||
@@ -2,10 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -99,7 +99,7 @@ func (kcl *KubeClient) isSystemCronJob(namespace string) bool {
|
||||
// DeleteCronJobs deletes the provided list of cronjobs in its namespace
|
||||
// it returns an error if any of the cronjobs are not found or if there is an error deleting the cronjobs
|
||||
func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) error {
|
||||
var errs error
|
||||
var errors []error
|
||||
for namespace := range payload {
|
||||
for _, cronJobName := range payload[namespace] {
|
||||
client := kcl.cli.BatchV1().CronJobs(namespace)
|
||||
@@ -110,14 +110,14 @@ func (kcl *KubeClient) DeleteCronJobs(payload models.K8sCronJobDeleteRequests) e
|
||||
continue
|
||||
}
|
||||
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), cronJobName, metav1.DeleteOptions{}); err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
return errorlist.Combine(errors)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
batchv1 "k8s.io/api/batch/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -190,7 +190,7 @@ func (kcl *KubeClient) getCronJobExecutions(cronJobName string, jobs *batchv1.Jo
|
||||
// DeleteJobs deletes the provided list of jobs
|
||||
// it returns an error if any of the jobs are not found or if there is an error deleting the jobs
|
||||
func (kcl *KubeClient) DeleteJobs(payload models.K8sJobDeleteRequests) error {
|
||||
var errs error
|
||||
var errors []error
|
||||
for namespace := range payload {
|
||||
for _, jobName := range payload[namespace] {
|
||||
client := kcl.cli.BatchV1().Jobs(namespace)
|
||||
@@ -201,16 +201,16 @@ func (kcl *KubeClient) DeleteJobs(payload models.K8sJobDeleteRequests) error {
|
||||
continue
|
||||
}
|
||||
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), jobName, metav1.DeleteOptions{}); err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
return errorlist.Combine(errors)
|
||||
}
|
||||
|
||||
// getLatestJobCondition returns the latest condition of the job
|
||||
|
||||
@@ -2,10 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -131,7 +131,7 @@ func (kcl *KubeClient) isSystemRole(role *rbacv1.Role) bool {
|
||||
// DeleteRoles processes a K8sServiceDeleteRequest by deleting each role
|
||||
// in its given namespace.
|
||||
func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
|
||||
var errs error
|
||||
var errors []error
|
||||
for namespace := range reqs {
|
||||
for _, name := range reqs[namespace] {
|
||||
client := kcl.cli.RbacV1().Roles(namespace)
|
||||
@@ -151,10 +151,10 @@ func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
|
||||
}
|
||||
|
||||
if err := client.Delete(context.TODO(), name, metav1.DeleteOptions{}); err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
return errorlist.Combine(errors)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
"github.com/rs/zerolog/log"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -104,7 +104,7 @@ func (kcl *KubeClient) getRole(namespace, name string) (*rbacv1.Role, error) {
|
||||
// DeleteRoleBindings processes a K8sServiceDeleteRequest by deleting each service
|
||||
// in its given namespace.
|
||||
func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error {
|
||||
var errs error
|
||||
var errors []error
|
||||
for namespace := range reqs {
|
||||
for _, name := range reqs[namespace] {
|
||||
client := kcl.cli.RbacV1().RoleBindings(namespace)
|
||||
@@ -124,9 +124,9 @@ func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteReques
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
return errorlist.Combine(errors)
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/errorlist"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -92,7 +92,7 @@ func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool {
|
||||
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
|
||||
// in its given namespace.
|
||||
func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error {
|
||||
var errs error
|
||||
var errors []error
|
||||
for namespace := range reqs {
|
||||
for _, serviceName := range reqs[namespace] {
|
||||
client := kcl.cli.CoreV1().ServiceAccounts(namespace)
|
||||
@@ -111,12 +111,12 @@ func (kcl *KubeClient) DeleteServiceAccounts(reqs models.K8sServiceAccountDelete
|
||||
}
|
||||
|
||||
if err := client.Delete(context.Background(), serviceName, metav1.DeleteOptions{}); err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
return errorlist.Combine(errors)
|
||||
}
|
||||
|
||||
// GetServiceAccountBearerToken returns the ServiceAccountToken associated to the specified user.
|
||||
|
||||
@@ -4,7 +4,6 @@ package validation
|
||||
// https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -17,31 +16,31 @@ const DNS1123SubdomainMaxLength int = 253
|
||||
var dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$")
|
||||
|
||||
// IsDNS1123Subdomain tests for a string that conforms to the definition of a subdomain in DNS (RFC 1123).
|
||||
func IsDNS1123Subdomain(value string) error {
|
||||
var errs error
|
||||
func IsDNS1123Subdomain(value string) []string {
|
||||
var errs []string
|
||||
if len(value) > DNS1123SubdomainMaxLength {
|
||||
errs = errors.Join(errs, MaxLenError(DNS1123SubdomainMaxLength))
|
||||
errs = append(errs, MaxLenError(DNS1123SubdomainMaxLength))
|
||||
}
|
||||
if !dns1123SubdomainRegexp.MatchString(value) {
|
||||
errs = errors.Join(errs, RegexError(dns1123SubdomainFmt, "example.com"))
|
||||
errs = append(errs, RegexError(dns1123SubdomainFmt, "example.com"))
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// MaxLenError returns a string explanation of a "string too long" validation failure.
|
||||
func MaxLenError(length int) error {
|
||||
return fmt.Errorf("must be no more than %d characters", length)
|
||||
func MaxLenError(length int) string {
|
||||
return fmt.Sprintf("must be no more than %d characters", length)
|
||||
}
|
||||
|
||||
// RegexError returns a string explanation of a regex validation failure.
|
||||
func RegexError(fmt string, examples ...string) error {
|
||||
func RegexError(fmt string, examples ...string) string {
|
||||
var s strings.Builder
|
||||
|
||||
_, _ = s.WriteString("must match the regex ")
|
||||
_, _ = s.WriteString(fmt)
|
||||
|
||||
if len(examples) == 0 {
|
||||
return errors.New(s.String())
|
||||
return s.String()
|
||||
}
|
||||
|
||||
s.WriteString(" (e.g. ")
|
||||
@@ -57,5 +56,5 @@ func RegexError(fmt string, examples ...string) error {
|
||||
|
||||
_, _ = s.WriteRune(')')
|
||||
|
||||
return errors.New(s.String())
|
||||
return s.String()
|
||||
}
|
||||
|
||||
@@ -30,11 +30,8 @@ func NewService() Service {
|
||||
// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token environment(endpoint).
|
||||
// On success, it will then return the username and token expiry time associated to authenticated user by fetching this information
|
||||
// from the resource server and matching it with the user identifier setting.
|
||||
func (Service) Authenticate(ctx context.Context, code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
|
||||
token, err := GetOAuthToken(ctx, code, configuration)
|
||||
func (Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
token, err := GetOAuthToken(code, configuration)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed retrieving oauth token")
|
||||
|
||||
@@ -46,7 +43,7 @@ func (Service) Authenticate(ctx context.Context, code string, configuration *por
|
||||
log.Error().Err(err).Msg("failed parsing id_token")
|
||||
}
|
||||
|
||||
resource, err := GetResource(ctx, token.AccessToken, configuration.ResourceURI)
|
||||
resource, err := GetResource(token.AccessToken, configuration.ResourceURI)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed retrieving resource")
|
||||
|
||||
@@ -65,7 +62,7 @@ func (Service) Authenticate(ctx context.Context, code string, configuration *por
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func GetOAuthToken(ctx context.Context, code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
unescapedCode, err := url.QueryUnescape(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -73,6 +70,9 @@ func GetOAuthToken(ctx context.Context, code string, configuration *portainer.OA
|
||||
|
||||
config := buildConfig(configuration)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
return config.Exchange(ctx, unescapedCode)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,9 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
jwtParser := jwt.Parser{SkipClaimsValidation: true}
|
||||
jwtParser := jwt.Parser{
|
||||
SkipClaimsValidation: true,
|
||||
}
|
||||
|
||||
t, _, err := jwtParser.ParseUnverified(idToken.(string), jwt.MapClaims{})
|
||||
if err != nil {
|
||||
@@ -101,15 +103,16 @@ func GetIdToken(token *oauth2.Token) (map[string]any, error) {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func GetResource(ctx context.Context, token string, resourceURI string) (map[string]any, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURI, nil)
|
||||
func GetResource(token string, resourceURI string) (map[string]any, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, resourceURI, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@ func Test_getOAuthToken(t *testing.T) {
|
||||
|
||||
t.Run("getOAuthToken fails upon invalid code", func(t *testing.T) {
|
||||
code := ""
|
||||
if _, err := GetOAuthToken(t.Context(), code, config); err == nil {
|
||||
if _, err := GetOAuthToken(code, config); err == nil {
|
||||
t.Errorf("getOAuthToken should fail upon providing invalid code; code=%v", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("getOAuthToken succeeds upon providing valid code", func(t *testing.T) {
|
||||
code := validCode
|
||||
token, err := GetOAuthToken(t.Context(), code, config)
|
||||
token, err := GetOAuthToken(code, config)
|
||||
|
||||
if token == nil || err != nil {
|
||||
t.Errorf("getOAuthToken should successfully return access token upon providing valid code")
|
||||
@@ -92,19 +92,19 @@ func Test_getResource(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("should fail upon missing Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource(t.Context(), "", config.ResourceURI); err == nil {
|
||||
if _, err := GetResource("", config.ResourceURI); err == nil {
|
||||
t.Errorf("getResource should fail if access token is not provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should fail upon providing incorrect Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource(t.Context(), "incorrect-token", config.ResourceURI); err == nil {
|
||||
if _, err := GetResource("incorrect-token", config.ResourceURI); err == nil {
|
||||
t.Errorf("getResource should fail if incorrect access token provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should succeed upon providing correct Authorization Bearer header", func(t *testing.T) {
|
||||
if _, err := GetResource(t.Context(), oauthtest.AccessToken, config.ResourceURI); err != nil {
|
||||
if _, err := GetResource(oauthtest.AccessToken, config.ResourceURI); err != nil {
|
||||
t.Errorf("getResource should succeed if correct access token provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
@@ -118,7 +118,7 @@ func Test_Authenticate(t *testing.T) {
|
||||
srv, config := oauthtest.RunOAuthServer(code, &portainer.OAuthSettings{})
|
||||
defer srv.Close()
|
||||
|
||||
if _, err := authService.Authenticate(t.Context(), code, config); err == nil {
|
||||
if _, err := authService.Authenticate(code, config); err == nil {
|
||||
t.Error("Authenticate should fail to extract username from resource if incorrect UserIdentifier provided")
|
||||
}
|
||||
})
|
||||
@@ -128,7 +128,7 @@ func Test_Authenticate(t *testing.T) {
|
||||
srv, config := oauthtest.RunOAuthServer(code, config)
|
||||
defer srv.Close()
|
||||
|
||||
username, err := authService.Authenticate(t.Context(), code, config)
|
||||
username, err := authService.Authenticate(code, config)
|
||||
if err != nil {
|
||||
t.Errorf("Authenticate should succeed to extract username from resource if correct UserIdentifier provided; UserIdentifier=%s", config.UserIdentifier)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
@@ -21,34 +20,38 @@ type PendingActionsService struct {
|
||||
|
||||
var handlers = make(map[string]portainer.PendingActionHandler)
|
||||
|
||||
func NewService(dataStore dataservices.DataStore, kubeFactory *kubecli.ClientFactory) *PendingActionsService {
|
||||
return &PendingActionsService{dataStore: dataStore, kubeFactory: kubeFactory}
|
||||
func NewService(
|
||||
dataStore dataservices.DataStore,
|
||||
kubeFactory *kubecli.ClientFactory,
|
||||
) *PendingActionsService {
|
||||
return &PendingActionsService{
|
||||
dataStore: dataStore,
|
||||
kubeFactory: kubeFactory,
|
||||
mu: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) RegisterHandler(name string, handler portainer.PendingActionHandler) {
|
||||
handlers[name] = handler
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) Create(tx dataservices.DataStoreTx, action portainer.PendingAction) error {
|
||||
func (service *PendingActionsService) Create(action portainer.PendingAction) error {
|
||||
// Check if this pendingAction already exists
|
||||
pendingActions, err := tx.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
|
||||
return a.EndpointID == action.EndpointID && a.Action == action.Action && reflect.DeepEqual(a.ActionData, action.ActionData)
|
||||
})
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve pending actions: %w", err)
|
||||
}
|
||||
|
||||
if len(pendingActions) > 0 {
|
||||
for _, dba := range pendingActions {
|
||||
// Same endpoint, same action and data, don't create a repeat
|
||||
log.Debug().
|
||||
Str("action", action.Action).
|
||||
Int("endpoint_id", int(action.EndpointID)).
|
||||
Msg("pending action already exists for environment, skipping...")
|
||||
|
||||
return nil
|
||||
if dba.EndpointID == action.EndpointID && dba.Action == action.Action &&
|
||||
reflect.DeepEqual(dba.ActionData, action.ActionData) {
|
||||
log.Debug().Msgf("pending action %s already exists for environment %d, skipping...", action.Action, action.EndpointID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return tx.PendingActions().Create(&action)
|
||||
return service.dataStore.PendingActions().Create(&action)
|
||||
}
|
||||
|
||||
func (service *PendingActionsService) Execute(id portainer.EndpointID) {
|
||||
@@ -62,8 +65,7 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
|
||||
|
||||
endpoint, err := service.dataStore.Endpoint().Endpoint(environmentID)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Int("endpoint_id", int(environmentID)).Msg("failed to retrieve environment")
|
||||
|
||||
log.Debug().Msgf("failed to retrieve environment %d: %v", environmentID, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -84,55 +86,48 @@ func (service *PendingActionsService) execute(environmentID portainer.EndpointID
|
||||
// creating a kube client and performing a simple operation
|
||||
client, err := service.kubeFactory.GetPrivilegedKubeClient(endpoint)
|
||||
if err != nil {
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Int("endpoint_id", int(environmentID)).
|
||||
Msg("failed to create Kubernetes client for environment")
|
||||
|
||||
log.Debug().Msgf("failed to create Kubernetes client for environment %d: %v", environmentID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = client.ServerVersion(); err != nil {
|
||||
log.Debug().
|
||||
Err(err).
|
||||
Str("endpoint_name", endpoint.Name).
|
||||
Int("endpoint_id", int(environmentID)).
|
||||
Msg("environment is not up")
|
||||
|
||||
log.Debug().Err(err).Msgf("Environment %q (id: %d) is not up", endpoint.Name, environmentID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll(func(a portainer.PendingAction) bool {
|
||||
return a.EndpointID == environmentID
|
||||
})
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("failed to read pending actions")
|
||||
log.Warn().Msgf("failed to read pending actions: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(pendingActions) > 0 {
|
||||
log.Debug().Int("pending_action_count", len(pendingActions)).Msg("found pending actions")
|
||||
log.Debug().Msgf("Found %d pending actions", len(pendingActions))
|
||||
}
|
||||
|
||||
for _, pendingAction := range pendingActions {
|
||||
log.Debug().
|
||||
Int("pending_action_id", int(pendingAction.ID)).
|
||||
Str("action", pendingAction.Action).
|
||||
Msg("executing pending action")
|
||||
if err := service.executePendingAction(pendingAction, endpoint); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to execute pending action")
|
||||
for i, pendingAction := range pendingActions {
|
||||
if pendingAction.EndpointID == environmentID {
|
||||
if i == 0 {
|
||||
// We have at least 1 pending action for this environment
|
||||
log.Debug().Msgf("Executing pending actions for environment %d", environmentID)
|
||||
}
|
||||
|
||||
continue
|
||||
log.Debug().Msgf("executing pending action id=%d, action=%s", pendingAction.ID, pendingAction.Action)
|
||||
err := service.executePendingAction(pendingAction, endpoint)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to execute pending action: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = service.dataStore.PendingActions().Delete(pendingAction.ID)
|
||||
if err != nil {
|
||||
log.Warn().Msgf("failed to delete pending action: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug().Msgf("pending action %d finished", pendingAction.ID)
|
||||
}
|
||||
|
||||
if err := service.dataStore.PendingActions().Delete(pendingAction.ID); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to delete pending action")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug().Int("pending_action_id", int(pendingAction.ID)).Msg("pending action finished")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +140,7 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
|
||||
|
||||
handler, ok := handlers[pendingAction.Action]
|
||||
if !ok {
|
||||
log.Warn().Str("action", pendingAction.Action).Msg("no handler found for pending action")
|
||||
|
||||
log.Warn().Msgf("no handler found for pending action %s", pendingAction.Action)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -355,20 +355,6 @@ type (
|
||||
CreatedBy string `example:"admin"`
|
||||
}
|
||||
|
||||
// HelmConfig represents the Helm configuration for an edge stack
|
||||
HelmConfig struct {
|
||||
// Path to a Helm chart folder for Helm git deployments
|
||||
ChartPath string `json:"ChartPath,omitempty" example:"charts/my-app"`
|
||||
// Array of paths to Helm values YAML files for Helm git deployments
|
||||
ValuesFiles []string `json:"ValuesFiles,omitempty" example:"['values/prod.yaml', 'values/secrets.yaml']"`
|
||||
// Helm chart version from Chart.yaml (read-only, extracted during Git sync)
|
||||
Version string `json:"Version,omitempty" example:"1.2.3"`
|
||||
// Enable automatic rollback on deployment failure (equivalent to helm --atomic flag)
|
||||
Atomic bool `json:"Atomic" example:"true"`
|
||||
// Timeout for Helm operations (equivalent to helm --timeout flag)
|
||||
Timeout string `json:"Timeout,omitempty" example:"5m0s"`
|
||||
}
|
||||
|
||||
EdgeStackStatusForEnv struct {
|
||||
EndpointID EndpointID
|
||||
Status []EdgeStackDeploymentStatus
|
||||
@@ -558,14 +544,11 @@ type (
|
||||
}
|
||||
|
||||
PolicyChartStatus struct {
|
||||
// EnvironmentID is the endpoint this status belongs to.
|
||||
// Stored so that ReadAll can group statuses by endpoint without parsing keys.
|
||||
EnvironmentID EndpointID `json:"environmentID,omitempty"`
|
||||
ChartName string `json:"chartName"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Status HelmInstallStatus `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Namespace string `json:"namespace"`
|
||||
ChartName string `json:"chartName"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Status HelmInstallStatus `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Namespace string `json:"namespace"`
|
||||
// Unix timestamp
|
||||
LastAttemptTime int64 `json:"lastAttemptTime"`
|
||||
}
|
||||
@@ -576,7 +559,7 @@ type (
|
||||
}
|
||||
|
||||
PolicyChartBundle struct {
|
||||
PolicyChartSummary `mapstructure:",squash"`
|
||||
PolicyChartSummary
|
||||
EncodedTgz string `json:"EncodedTgz"`
|
||||
Namespace string `json:"Namespace"`
|
||||
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
|
||||
@@ -647,8 +630,6 @@ type (
|
||||
AllowContainerCapabilitiesForRegularUsers bool `json:"allowContainerCapabilitiesForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use sysctl settings
|
||||
AllowSysctlSettingForRegularUsers bool `json:"allowSysctlSettingForRegularUsers" example:"true"`
|
||||
// Whether non-administrator should be able to use security-opt settings
|
||||
AllowSecurityOptForRegularUsers bool `json:"allowSecurityOptForRegularUsers" example:"true"`
|
||||
// Whether host management features are enabled
|
||||
EnableHostManagementFeatures bool `json:"enableHostManagementFeatures" example:"true"`
|
||||
}
|
||||
@@ -1836,7 +1817,7 @@ type (
|
||||
|
||||
// OAuthService represents a service used to authenticate users using OAuth
|
||||
OAuthService interface {
|
||||
Authenticate(ctx context.Context, code string, configuration *OAuthSettings) (string, error)
|
||||
Authenticate(code string, configuration *OAuthSettings) (string, error)
|
||||
}
|
||||
|
||||
// ReverseTunnelService represents a service used to manage reverse tunnel connections.
|
||||
@@ -1876,9 +1857,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.39.0"
|
||||
APIVersion = "2.38.1"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "LTS"
|
||||
APIVersionSupport = "STS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
@@ -2479,7 +2460,6 @@ func DefaultEndpointSecuritySettings() EndpointSecuritySettings {
|
||||
AllowHostNamespaceForRegularUsers: false,
|
||||
AllowPrivilegedModeForRegularUsers: false,
|
||||
AllowSysctlSettingForRegularUsers: false,
|
||||
AllowSecurityOptForRegularUsers: false,
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
|
||||
|
||||
@@ -74,10 +74,18 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
|
||||
}
|
||||
}
|
||||
|
||||
return d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
|
||||
if err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
|
||||
ComposeOptions: options,
|
||||
ForceRecreate: forceRecreate,
|
||||
})
|
||||
}); err != nil {
|
||||
if err := d.composeStackManager.Down(context.TODO(), stack, endpoint); err != nil {
|
||||
log.Warn().Err(err).Msg("failed to cleanup compose stack after failed deployment")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
|
||||
|
||||
@@ -56,10 +56,6 @@ func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.Endpo
|
||||
return errors.New("sysctl setting disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !securitySettings.AllowSecurityOptForRegularUsers && len(service.SecurityOpt) > 0 {
|
||||
return errors.New("security-opt setting disabled for non administrator users")
|
||||
}
|
||||
|
||||
if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) {
|
||||
return errors.New("container capabilities disabled for non administrator users")
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ div.input-mask {
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
background: var(--bg-widget-color);
|
||||
border: 1px solid var(--border-widget);
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.widget .widget-header .pagination,
|
||||
.widget .widget-footer .pagination {
|
||||
@@ -103,7 +103,7 @@ div.input-mask {
|
||||
|
||||
.widget .widget-body {
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.widget .widget-body table thead {
|
||||
background: var(--bg-widget-table-color);
|
||||
|
||||
@@ -24,7 +24,6 @@ export default class DockerFeaturesConfigurationController {
|
||||
disableDeviceMappingForRegularUsers: false,
|
||||
disableContainerCapabilitiesForRegularUsers: false,
|
||||
disableSysctlSettingForRegularUsers: false,
|
||||
disableSecurityOptForRegularUsers: false,
|
||||
};
|
||||
|
||||
this.isAgent = false;
|
||||
@@ -49,7 +48,6 @@ export default class DockerFeaturesConfigurationController {
|
||||
this.onChangeDisableDeviceMappingForRegularUsers = this.onChangeField('disableDeviceMappingForRegularUsers');
|
||||
this.onChangeDisableContainerCapabilitiesForRegularUsers = this.onChangeField('disableContainerCapabilitiesForRegularUsers');
|
||||
this.onChangeDisableSysctlSettingForRegularUsers = this.onChangeField('disableSysctlSettingForRegularUsers');
|
||||
this.onChangeDisableSecurityOptForRegularUsers = this.onChangeField('disableSecurityOptForRegularUsers');
|
||||
}
|
||||
|
||||
onToggleAutoUpdate(value) {
|
||||
@@ -95,7 +93,6 @@ export default class DockerFeaturesConfigurationController {
|
||||
disableDeviceMappingForRegularUsers,
|
||||
disableContainerCapabilitiesForRegularUsers,
|
||||
disableSysctlSettingForRegularUsers,
|
||||
disableSecurityOptForRegularUsers,
|
||||
} = this.formValues;
|
||||
return (
|
||||
disableBindMountsForRegularUsers ||
|
||||
@@ -103,8 +100,7 @@ export default class DockerFeaturesConfigurationController {
|
||||
disablePrivilegedModeForRegularUsers ||
|
||||
disableDeviceMappingForRegularUsers ||
|
||||
disableContainerCapabilitiesForRegularUsers ||
|
||||
disableSysctlSettingForRegularUsers ||
|
||||
disableSecurityOptForRegularUsers
|
||||
disableSysctlSettingForRegularUsers
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,7 +122,6 @@ export default class DockerFeaturesConfigurationController {
|
||||
allowStackManagementForRegularUsers: !this.formValues.disableStackManagementForRegularUsers,
|
||||
allowContainerCapabilitiesForRegularUsers: !this.formValues.disableContainerCapabilitiesForRegularUsers,
|
||||
allowSysctlSettingForRegularUsers: !this.formValues.disableSysctlSettingForRegularUsers,
|
||||
allowSecurityOptForRegularUsers: !this.formValues.disableSecurityOptForRegularUsers,
|
||||
enableGPUManagement: this.state.enableGPUManagement,
|
||||
gpus,
|
||||
};
|
||||
@@ -164,7 +159,6 @@ export default class DockerFeaturesConfigurationController {
|
||||
disableStackManagementForRegularUsers: !securitySettings.allowStackManagementForRegularUsers,
|
||||
disableContainerCapabilitiesForRegularUsers: !securitySettings.allowContainerCapabilitiesForRegularUsers,
|
||||
disableSysctlSettingForRegularUsers: !securitySettings.allowSysctlSettingForRegularUsers,
|
||||
disableSecurityOptForRegularUsers: !securitySettings.allowSecurityOptForRegularUsers,
|
||||
};
|
||||
|
||||
// this.endpoint.Gpus could be null as it is Gpus: []Pair in the API
|
||||
|
||||
@@ -142,17 +142,6 @@
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
checked="$ctrl.formValues.disableSecurityOptForRegularUsers"
|
||||
name="'disableSecurityOptForRegularUsers'"
|
||||
label="'Disable security-opt for non-administrators'"
|
||||
label-class="'col-sm-7 col-lg-4'"
|
||||
on-change="($ctrl.onChangeDisableSecurityOptForRegularUsers)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.isContainerEditDisabled()">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
|
||||
11
app/index.js
11
app/index.js
@@ -61,8 +61,11 @@ angular
|
||||
.config(configApp);
|
||||
|
||||
if (require) {
|
||||
const req = require.context('./', true, /^(?!.*\.test\.js$).*\.js$/im);
|
||||
req.keys().forEach(function (key) {
|
||||
req(key);
|
||||
});
|
||||
const req = require.context('./', true, /^(.*\.(js$))[^.]*$/im);
|
||||
req
|
||||
.keys()
|
||||
.filter((path) => !path.includes('.test'))
|
||||
.forEach(function (key) {
|
||||
req(key);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,7 +195,8 @@ angular
|
||||
},
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'environmentsItemView',
|
||||
templateUrl: './views/endpoints/edit/endpoint.html',
|
||||
controller: 'EndpointController',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,74 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { EdgeKeyDisplay } from '@/react/portainer/environments/ItemView/EdgeKeyDisplay';
|
||||
import { EdgeAgentDeploymentWidget } from '@/react/portainer/environments/ItemView/EdgeAgentDeploymentWidget/EdgeAgentDeploymentWidget';
|
||||
import { KVMControl } from '@/react/portainer/environments/KvmView/KVMControl';
|
||||
import { TagsDatatable } from '@/react/portainer/environments/TagsView/TagsDatatable';
|
||||
import { EnvironmentBasicConfigSection } from '@/react/portainer/environments/ItemView/EnvironmentBasicConfigSection/EnvironmentBasicConfigSection';
|
||||
import { EdgeInformationPanel } from '@/react/portainer/environments/ItemView/EdgeInformationPanel/EdgeInformationPanel';
|
||||
import { KubeConfigInfo } from '@/react/portainer/environments/ItemView/KubeConfigInfo/KubeConfigInfo';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { AzureEnvironmentForm } from '@/react/portainer/environments/ItemView/AzureEnvironmentForm/AzureEnvironmentForm';
|
||||
import { GeneralEnvironmentForm } from '@/react/portainer/environments/ItemView/GeneralEnvironmentForm/GeneralEnvironmentForm';
|
||||
|
||||
export const environmentsModule = angular
|
||||
.module('portainer.app.react.components.environments', [])
|
||||
.component('edgeKeyDisplay', r2a(EdgeKeyDisplay, ['edgeKey']))
|
||||
.component(
|
||||
'edgeAgentDeploymentWidget',
|
||||
r2a(withCurrentUser(withUIRouter(EdgeAgentDeploymentWidget)), [
|
||||
'edgeKey',
|
||||
'edgeId',
|
||||
'asyncMode',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'kubeConfigInfo',
|
||||
r2a(withUIRouter(withReactQuery(KubeConfigInfo)), [
|
||||
'environmentId',
|
||||
'environmentType',
|
||||
'edgeId',
|
||||
'status',
|
||||
])
|
||||
)
|
||||
.component('kvmControl', r2a(KVMControl, ['deviceId', 'server', 'token']))
|
||||
.component('tagsDatatable', r2a(TagsDatatable, ['dataset', 'onRemove'])).name;
|
||||
.component('tagsDatatable', r2a(TagsDatatable, ['dataset', 'onRemove']))
|
||||
.component(
|
||||
'environmentBasicConfigSection',
|
||||
r2a(EnvironmentBasicConfigSection, [
|
||||
'values',
|
||||
'setValues',
|
||||
'isEdge',
|
||||
'isAzure',
|
||||
'isAgent',
|
||||
'hasError',
|
||||
'isLocalEnvironment',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'edgeInformationPanel',
|
||||
r2a(withUIRouter(withReactQuery(EdgeInformationPanel)), [
|
||||
'environmentId',
|
||||
'edgeKey',
|
||||
'edgeId',
|
||||
'platformName',
|
||||
'onSuccess',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'azureEnvironmentForm',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(AzureEnvironmentForm))), [
|
||||
'environment',
|
||||
'onSuccess',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'generalEnvironmentForm',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(GeneralEnvironmentForm))), [
|
||||
'environment',
|
||||
'onSuccess',
|
||||
])
|
||||
).name;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { ListView } from '@/react/portainer/environments/ListView';
|
||||
import { EdgeAutoCreateScriptViewWrapper } from '@/react/portainer/environments/EdgeAutoCreateScriptView/EdgeAutoCreateScriptView';
|
||||
import { ItemView } from '@/react/portainer/environments/ItemView/ItemView';
|
||||
|
||||
export const environmentsModule = angular
|
||||
.module('portainer.app.react.views.environments', [])
|
||||
.component(
|
||||
'environmentsListView',
|
||||
r2a(withUIRouter(withCurrentUser(ListView)), [])
|
||||
)
|
||||
.component(
|
||||
'environmentsItemView',
|
||||
r2a(withUIRouter(withCurrentUser(ItemView)), [])
|
||||
)
|
||||
.component(
|
||||
'edgeAutoCreateScriptView',
|
||||
r2a(withUIRouter(withCurrentUser(EdgeAutoCreateScriptViewWrapper)), [])
|
||||
).name;
|
||||
@@ -7,6 +7,8 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { CreateUserAccessToken } from '@/react/portainer/account/CreateAccessTokenView';
|
||||
import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeView/EdgeComputeSettingsView';
|
||||
import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView';
|
||||
import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView';
|
||||
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
|
||||
import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView';
|
||||
import { CreateHelmRepositoriesView } from '@/react/portainer/account/helm-repositories/CreateHelmRepositoryView';
|
||||
@@ -19,7 +21,6 @@ import { registriesModule } from './registries';
|
||||
import { activityLogsModule } from './activity-logs';
|
||||
import { templatesModule } from './templates';
|
||||
import { usersModule } from './users';
|
||||
import { environmentsModule } from './environments';
|
||||
|
||||
export const viewsModule = angular
|
||||
.module('portainer.app.react.views', [
|
||||
@@ -31,12 +32,18 @@ export const viewsModule = angular
|
||||
activityLogsModule,
|
||||
templatesModule,
|
||||
usersModule,
|
||||
environmentsModule,
|
||||
])
|
||||
.component(
|
||||
'homeView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(HomeView))), [])
|
||||
)
|
||||
.component(
|
||||
'edgeAutoCreateScriptView',
|
||||
r2a(
|
||||
withUIRouter(withReactQuery(withCurrentUser(EdgeAutoCreateScriptView))),
|
||||
[]
|
||||
)
|
||||
)
|
||||
.component(
|
||||
'createUserAccessToken',
|
||||
r2a(
|
||||
@@ -51,7 +58,10 @@ export const viewsModule = angular
|
||||
['onSubmit', 'settings']
|
||||
)
|
||||
)
|
||||
|
||||
.component(
|
||||
'environmentsListView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(EnvironmentsListView))), [])
|
||||
)
|
||||
.component(
|
||||
'backupSettingsPanel',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(BackupSettingsPanel))), [])
|
||||
|
||||
181
app/portainer/views/endpoints/edit/endpoint.html
Normal file
181
app/portainer/views/endpoints/edit/endpoint.html
Normal file
@@ -0,0 +1,181 @@
|
||||
<page-header ng-if="endpoint" title="'Environment details'" breadcrumbs="[{label:'Environments', link:'portainer.endpoints'}, endpoint.Name]" reload="true"> </page-header>
|
||||
|
||||
<div>
|
||||
<div class="mx-4 space-y-4 [&>*]:block mb-4" ng-if="state.edgeEndpoint">
|
||||
<edge-information-panel
|
||||
ng-if="state.edgeAssociated"
|
||||
environment-id="endpoint.Id"
|
||||
edge-key="endpoint.EdgeKey"
|
||||
edge-id="endpoint.EdgeID"
|
||||
platform-name="state.platformName"
|
||||
on-success="(onDisassociateSuccess)"
|
||||
>
|
||||
</edge-information-panel>
|
||||
|
||||
<div ng-if="!state.edgeAssociated">
|
||||
<edge-agent-deployment-widget edge-key="endpoint.EdgeKey" edge-id="endpoint.EdgeID" async-mode="endpoint.Edge.AsyncMode"></edge-agent-deployment-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-4 space-y-4 [&>*]:block mb-4">
|
||||
<kube-config-info environment-id="endpoint.Id" environment-type="endpoint.Type" edge-id="endpoint.EdgeID" status="endpoint.Status"></kube-config-info>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="state.azureEndpoint" class="col-sm-12">
|
||||
<azure-environment-form environment="endpoint" on-cancel="(cancelUpdateEndpoint)" on-success="(onUpdateSuccess)"></azure-environment-form>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4" ng-if="!state.azureEndpoint && !state.edgeEndpoint">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12" ng-if="endpoint">
|
||||
<general-environment-form environment="endpoint" on-cancel="(cancelUpdateEndpoint)" on-success="(onUpdateSuccess)"></general-environment-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4" ng-if="state.edgeEndpoint">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="$ctrl.endpointForm">
|
||||
<environment-basic-config-section
|
||||
values="basicConfigValues"
|
||||
set-values="updateBasicConfig"
|
||||
is-edge="state.edgeEndpoint"
|
||||
is-azure="state.azureEndpoint"
|
||||
is-agent="state.agentEndpoint"
|
||||
has-error="endpoint.Status === 4"
|
||||
is-local-environment="endpointType === 'local'"
|
||||
></environment-basic-config-section>
|
||||
|
||||
<div ng-if="endpoint && state.edgeEndpoint">
|
||||
<div class="col-sm-12 form-section-title"> Check-in Intervals </div>
|
||||
<edge-checkin-interval-field value="endpoint.EdgeCheckinInterval" on-change="(onChangeCheckInInterval)"></edge-checkin-interval-field>
|
||||
</div>
|
||||
|
||||
<!-- !endpoint-public-url-input -->
|
||||
|
||||
<tls-fieldset
|
||||
ng-if="!state.edgeEndpoint && endpoint.Status !== 4 && state.showTLSConfig"
|
||||
values="formValues.tlsConfig"
|
||||
on-change="(onChangeTLSConfigFormValues)"
|
||||
validation-data="{optionalCert: true}"
|
||||
></tls-fieldset>
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Metadata </div>
|
||||
<!-- group -->
|
||||
<div class="form-group">
|
||||
<label for="endpoint_group" class="col-sm-3 col-lg-2 control-label text-left"> Group </label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<select
|
||||
ng-options="group.Id as group.Name for group in groups"
|
||||
ng-model="endpoint.GroupId"
|
||||
id="endpoint_group"
|
||||
class="form-control"
|
||||
data-cy="endpoint-group-select"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !group -->
|
||||
|
||||
<tag-selector ng-if="endpoint" value="endpoint.TagIds" allow-create="state.allowCreate" on-change="(onChangeTags)"></tag-selector>
|
||||
|
||||
<!-- open-amt info -->
|
||||
<div ng-if="state.showAMTInfo">
|
||||
<div class="col-sm-12 form-section-title"> Open Active Management Technology </div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endpoint_managementinfoVersion" class="col-sm-3 col-lg-2 control-label text-left"> AMT Version </label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input
|
||||
type="text"
|
||||
ng-disabled="true"
|
||||
class="form-control"
|
||||
id="endpoint_managementinfoVersion"
|
||||
ng-model="endpoint.ManagementInfo['AMT']"
|
||||
placeholder="Loading..."
|
||||
data-cy="endpoint-managementinfoVersion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endpoint_managementinfoUUID" class="col-sm-3 col-lg-2 control-label text-left"> UUID </label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input
|
||||
type="text"
|
||||
ng-disabled="true"
|
||||
class="form-control"
|
||||
id="endpoint_managementinfoUUID"
|
||||
ng-model="endpoint.ManagementInfo['UUID']"
|
||||
placeholder="Loading..."
|
||||
data-cy="endpoint-managementinfoUUID"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endpoint_managementinfoBuildNumber" class="col-sm-3 col-lg-2 control-label text-left"> Build Number </label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input
|
||||
type="text"
|
||||
data-cy="endpoint-managementinfoBuildNumber"
|
||||
ng-disabled="true"
|
||||
class="form-control"
|
||||
id="endpoint_managementinfoBuildNumber"
|
||||
ng-model="endpoint.ManagementInfo['Build Number']"
|
||||
placeholder="Loading..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endpoint_managementinfoControlMode" class="col-sm-3 col-lg-2 control-label text-left"> Control Mode </label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input
|
||||
type="text"
|
||||
data-cy="endpoint-managementinfoControlMode"
|
||||
ng-disabled="true"
|
||||
class="form-control"
|
||||
id="endpoint_managementinfoControlMode"
|
||||
ng-model="endpoint.ManagementInfo['Control Mode']"
|
||||
placeholder="Loading..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endpoint_managementinfoDNSSuffix" class="col-sm-3 col-lg-2 control-label text-left"> DNS Suffix </label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input
|
||||
type="text"
|
||||
data-cy="endpoint-managementinfoDNSSuffix"
|
||||
ng-disabled="true"
|
||||
class="form-control"
|
||||
id="endpoint_managementinfoDNSSuffix"
|
||||
ng-model="endpoint.ManagementInfo['DNS Suffix']"
|
||||
placeholder="Loading..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !open-amt info -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="state.actionInProgress || !endpoint.Name || !endpoint.URL || !$ctrl.endpointForm.$valid"
|
||||
ng-click="updateEndpoint()"
|
||||
button-spinner="state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="state.actionInProgress">Update environment</span>
|
||||
<span ng-show="state.actionInProgress">Updating environment...</span>
|
||||
</button>
|
||||
<a type="button" class="btn btn-default btn-sm" ui-sref="portainer.endpoints">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
344
app/portainer/views/endpoints/edit/endpointController.js
Normal file
344
app/portainer/views/endpoints/edit/endpointController.js
Normal file
@@ -0,0 +1,344 @@
|
||||
import _ from 'lodash-es';
|
||||
import uuidv4 from 'uuid/v4';
|
||||
|
||||
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
|
||||
import EndpointHelper from '@/portainer/helpers/endpointHelper';
|
||||
import { getAMTInfo } from 'Portainer/hostmanagement/open-amt/open-amt.service';
|
||||
import { confirmDestructive } from '@@/modals/confirm';
|
||||
import { getPlatformTypeName, isEdgeEnvironment, isDockerAPIEnvironment } from '@/react/portainer/environments/utils';
|
||||
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
|
||||
import { getInfo } from '@/react/docker/proxy/queries/useInfo';
|
||||
|
||||
angular.module('portainer.app').controller('EndpointController', EndpointController);
|
||||
|
||||
/* @ngInject */
|
||||
function EndpointController(
|
||||
$async,
|
||||
$scope,
|
||||
$state,
|
||||
$transition$,
|
||||
$filter,
|
||||
clipboard,
|
||||
EndpointService,
|
||||
GroupService,
|
||||
|
||||
Notifications,
|
||||
Authentication,
|
||||
SettingsService
|
||||
) {
|
||||
$scope.onChangeCheckInInterval = onChangeCheckInInterval;
|
||||
$scope.setFieldValue = setFieldValue;
|
||||
$scope.onChangeTags = onChangeTags;
|
||||
$scope.onChangeTLSConfigFormValues = onChangeTLSConfigFormValues;
|
||||
$scope.onDisassociateSuccess = onDisassociateSuccess;
|
||||
|
||||
$scope.state = {
|
||||
platformName: '',
|
||||
selectAll: false,
|
||||
// displayTextFilter: false,
|
||||
get selectedItemCount() {
|
||||
return $scope.state.selectedItems.length || 0;
|
||||
},
|
||||
selectedItems: [],
|
||||
uploadInProgress: false,
|
||||
actionInProgress: false,
|
||||
azureEndpoint: false,
|
||||
kubernetesEndpoint: false,
|
||||
agentEndpoint: false,
|
||||
edgeEndpoint: false,
|
||||
edgeAssociated: false,
|
||||
allowCreate: Authentication.isAdmin(),
|
||||
allowSelfSignedCerts: true,
|
||||
showAMTInfo: false,
|
||||
showTLSConfig: false,
|
||||
};
|
||||
|
||||
$scope.basicConfigValues = {
|
||||
name: '',
|
||||
url: '',
|
||||
publicUrl: '',
|
||||
};
|
||||
|
||||
$scope.selectAll = function () {
|
||||
$scope.state.firstClickedItem = null;
|
||||
for (var i = 0; i < $scope.state.filteredDataSet.length; i++) {
|
||||
var item = $scope.state.filteredDataSet[i];
|
||||
if (item.Checked !== $scope.state.selectAll) {
|
||||
// if ($scope.allowSelection(item) && item.Checked !== $scope.state.selectAll) {
|
||||
item.Checked = $scope.state.selectAll;
|
||||
$scope.selectItem(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function isBetween(value, a, b) {
|
||||
return (value >= a && value <= b) || (value >= b && value <= a);
|
||||
}
|
||||
|
||||
$scope.selectItem = function (item, event) {
|
||||
// Handle range select using shift
|
||||
if (event && event.originalEvent.shiftKey && $scope.state.firstClickedItem) {
|
||||
const firstItemIndex = $scope.state.filteredDataSet.indexOf($scope.state.firstClickedItem);
|
||||
const lastItemIndex = $scope.state.filteredDataSet.indexOf(item);
|
||||
const itemsInRange = _.filter($scope.state.filteredDataSet, (item, index) => {
|
||||
return isBetween(index, firstItemIndex, lastItemIndex);
|
||||
});
|
||||
const value = $scope.state.firstClickedItem.Checked;
|
||||
|
||||
_.forEach(itemsInRange, (i) => {
|
||||
i.Checked = value;
|
||||
});
|
||||
$scope.state.firstClickedItem = item;
|
||||
} else if (event) {
|
||||
item.Checked = !item.Checked;
|
||||
$scope.state.firstClickedItem = item;
|
||||
}
|
||||
$scope.state.selectedItems = _.uniq(_.concat($scope.state.selectedItems, $scope.state.filteredDataSet)).filter((i) => i.Checked);
|
||||
if (event && $scope.state.selectAll && $scope.state.selectedItems.length !== $scope.state.filteredDataSet.length) {
|
||||
$scope.state.selectAll = false;
|
||||
}
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
tlsConfig: {
|
||||
tls: false,
|
||||
skipVerify: false,
|
||||
skipClientVerify: false,
|
||||
caCertFile: null,
|
||||
certFile: null,
|
||||
keyFile: null,
|
||||
},
|
||||
};
|
||||
|
||||
function onDisassociateSuccess() {
|
||||
$state.reload();
|
||||
}
|
||||
|
||||
function onChangeCheckInInterval(value) {
|
||||
setFieldValue('EdgeCheckinInterval', value);
|
||||
}
|
||||
|
||||
function onChangeTags(value) {
|
||||
setFieldValue('TagIds', value);
|
||||
}
|
||||
|
||||
function onChangeTLSConfigFormValues(newValues) {
|
||||
return this.$async(async () => {
|
||||
$scope.formValues.tlsConfig = {
|
||||
...$scope.formValues.tlsConfig,
|
||||
...newValues,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setFieldValue(name, value) {
|
||||
return $scope.$evalAsync(() => {
|
||||
$scope.endpoint = {
|
||||
...$scope.endpoint,
|
||||
[name]: value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Array.prototype.indexOf = function (val) {
|
||||
for (var i = 0; i < this.length; i++) {
|
||||
if (this[i] == val) return i;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
Array.prototype.remove = function (val) {
|
||||
var index = this.indexOf(val);
|
||||
if (index > -1) {
|
||||
this.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
$scope.updateEndpoint = async function () {
|
||||
var endpoint = $scope.endpoint;
|
||||
|
||||
if (isEdgeEnvironment(endpoint.Type) && _.difference($scope.initialTagIds, endpoint.TagIds).length > 0) {
|
||||
let confirmed = await confirmDestructive({
|
||||
title: 'Confirm action',
|
||||
message: 'Removing tags from this environment will remove the corresponding edge stacks when dynamic grouping is being used',
|
||||
confirmButton: buildConfirmButton(),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var payload = {
|
||||
Name: endpoint.Name,
|
||||
PublicURL: endpoint.PublicURL,
|
||||
Gpus: endpoint.Gpus,
|
||||
GroupID: endpoint.GroupId,
|
||||
TagIds: endpoint.TagIds,
|
||||
EdgeCheckinInterval: endpoint.EdgeCheckinInterval,
|
||||
};
|
||||
|
||||
if (
|
||||
$scope.endpointType !== 'local' &&
|
||||
endpoint.Type !== PortainerEndpointTypes.AzureEnvironment &&
|
||||
endpoint.Type !== PortainerEndpointTypes.KubernetesLocalEnvironment &&
|
||||
endpoint.Type !== PortainerEndpointTypes.AgentOnKubernetesEnvironment
|
||||
) {
|
||||
payload.URL = 'tcp://' + endpoint.URL;
|
||||
|
||||
if (endpoint.Type === PortainerEndpointTypes.DockerEnvironment) {
|
||||
var tlsConfig = $scope.formValues.tlsConfig;
|
||||
payload.TLS = tlsConfig.tls;
|
||||
payload.TLSSkipVerify = tlsConfig.skipVerify;
|
||||
if (tlsConfig.tls && !tlsConfig.skipVerify) {
|
||||
payload.TLSSkipClientVerify = tlsConfig.skipClientVerify;
|
||||
payload.TLSCACert = tlsConfig.caCertFile;
|
||||
payload.TLSCert = tlsConfig.certFile;
|
||||
payload.TLSKey = tlsConfig.keyFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment) {
|
||||
payload.URL = endpoint.URL;
|
||||
}
|
||||
|
||||
if (endpoint.Type === PortainerEndpointTypes.KubernetesLocalEnvironment) {
|
||||
payload.URL = 'https://' + endpoint.URL;
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
EndpointService.updateEndpoint(endpoint.Id, payload).then(
|
||||
function success() {
|
||||
onUpdateSuccess();
|
||||
},
|
||||
function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to update environment');
|
||||
$scope.state.actionInProgress = false;
|
||||
},
|
||||
function update(evt) {
|
||||
if (evt.upload) {
|
||||
$scope.state.uploadInProgress = evt.upload;
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function onUpdateSuccess() {
|
||||
Notifications.success('Environment updated', $scope.endpoint.Name);
|
||||
$state.go($state.params.redirectTo || 'portainer.endpoints', {}, { reload: true });
|
||||
}
|
||||
|
||||
function configureState() {
|
||||
$scope.state.platformName = getPlatformTypeName($scope.endpoint.Type);
|
||||
|
||||
if (
|
||||
$scope.endpoint.Type === PortainerEndpointTypes.KubernetesLocalEnvironment ||
|
||||
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment ||
|
||||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
|
||||
) {
|
||||
$scope.state.kubernetesEndpoint = true;
|
||||
}
|
||||
if ($scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || $scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
|
||||
$scope.state.edgeEndpoint = true;
|
||||
}
|
||||
if ($scope.endpoint.Type === PortainerEndpointTypes.AzureEnvironment) {
|
||||
$scope.state.azureEndpoint = true;
|
||||
}
|
||||
if (
|
||||
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnDockerEnvironment ||
|
||||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment ||
|
||||
$scope.endpoint.Type === PortainerEndpointTypes.AgentOnKubernetesEnvironment ||
|
||||
$scope.endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
|
||||
) {
|
||||
$scope.state.agentEndpoint = true;
|
||||
}
|
||||
}
|
||||
|
||||
function configureTLS(endpoint) {
|
||||
$scope.formValues = {
|
||||
tlsConfig: {
|
||||
tls: endpoint.TLSConfig.TLS || false,
|
||||
skipVerify: endpoint.TLSConfig.TLSSkipVerify || false,
|
||||
skipClientVerify: endpoint.TLSConfig.TLSSkipClientVerify || false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function initView() {
|
||||
return $async(async () => {
|
||||
try {
|
||||
const [endpoint, groups, settings] = await Promise.all([EndpointService.endpoint($transition$.params().id), GroupService.groups(), SettingsService.settings()]);
|
||||
if (isDockerAPIEnvironment(endpoint)) {
|
||||
$scope.state.showTLSConfig = true;
|
||||
}
|
||||
|
||||
// Check if the environment is docker standalone, to decide whether to show the GPU insights box
|
||||
const isDockerEnvironment = endpoint.Type === PortainerEndpointTypes.DockerEnvironment;
|
||||
if (isDockerEnvironment) {
|
||||
try {
|
||||
const dockerInfo = await getInfo(endpoint.Id);
|
||||
const isDockerSwarmEnv = dockerInfo.Swarm && dockerInfo.Swarm.NodeID;
|
||||
$scope.isDockerStandaloneEnv = !isDockerSwarmEnv;
|
||||
} catch (err) {
|
||||
// $scope.isDockerStandaloneEnv is only used to show the "GPU insights box", so fail quietly on error
|
||||
}
|
||||
}
|
||||
|
||||
if (endpoint.URL.indexOf('unix://') === 0 || endpoint.URL.indexOf('npipe://') === 0) {
|
||||
$scope.endpointType = 'local';
|
||||
} else {
|
||||
$scope.endpointType = 'remote';
|
||||
}
|
||||
|
||||
if ($scope.state.azureEndpoint || $scope.state.edgeEndpoint) {
|
||||
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
|
||||
}
|
||||
|
||||
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnDockerEnvironment || endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
|
||||
$scope.state.edgeAssociated = !!endpoint.EdgeID;
|
||||
endpoint.EdgeID = endpoint.EdgeID || uuidv4();
|
||||
}
|
||||
|
||||
$scope.endpoint = endpoint;
|
||||
$scope.initialTagIds = endpoint.TagIds.slice();
|
||||
$scope.groups = groups;
|
||||
|
||||
configureState();
|
||||
|
||||
configureTLS(endpoint);
|
||||
|
||||
if (EndpointHelper.isDockerEndpoint(endpoint) && $scope.state.edgeAssociated) {
|
||||
$scope.state.showAMTInfo = settings && settings.openAMTConfiguration && settings.openAMTConfiguration.enabled;
|
||||
}
|
||||
} catch (err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve environment details');
|
||||
}
|
||||
|
||||
if ($scope.state.showAMTInfo) {
|
||||
try {
|
||||
$scope.endpoint.ManagementInfo = {};
|
||||
const amtInfo = await getAMTInfo($state.params.id);
|
||||
try {
|
||||
$scope.endpoint.ManagementInfo = JSON.parse(amtInfo.RawOutput);
|
||||
} catch (err) {
|
||||
clearAMTManagementInfo(amtInfo.RawOutput);
|
||||
}
|
||||
} catch (err) {
|
||||
clearAMTManagementInfo('Unable to retrieve AMT environment details');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearAMTManagementInfo(versionValue) {
|
||||
$scope.endpoint.ManagementInfo['AMT'] = versionValue;
|
||||
$scope.endpoint.ManagementInfo['UUID'] = '-';
|
||||
$scope.endpoint.ManagementInfo['Control Mode'] = '-';
|
||||
$scope.endpoint.ManagementInfo['Build Number'] = '-';
|
||||
$scope.endpoint.ManagementInfo['DNS Suffix'] = '-';
|
||||
}
|
||||
|
||||
initView();
|
||||
}
|
||||
@@ -135,7 +135,6 @@ export function createMockEnvironment(
|
||||
allowHostNamespaceForRegularUsers: false,
|
||||
allowStackManagementForRegularUsers: false,
|
||||
allowSysctlSettingForRegularUsers: false,
|
||||
allowSecurityOptForRegularUsers: false,
|
||||
allowVolumeBrowserForRegularUsers: false,
|
||||
enableHostManagementFeatures: false,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
@@ -17,50 +17,31 @@ vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderComponent() {
|
||||
test('submit button should be disabled when name or image is missing', async () => {
|
||||
server.use(http.get('/api/endpoints/5', () => HttpResponse.json({})));
|
||||
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
const Wrapped = withTestQueryProvider(
|
||||
withUserProvider(withTestRouter(CreateContainerInstanceForm), user)
|
||||
);
|
||||
const { findByText, getByText, getByLabelText } = render(<Wrapped />);
|
||||
|
||||
return render(<Wrapped />);
|
||||
}
|
||||
await expect(findByText(/Azure settings/)).resolves.toBeVisible();
|
||||
|
||||
describe('CreateContainerInstanceForm', () => {
|
||||
beforeEach(() => {
|
||||
server.use(http.get('/api/endpoints/5', () => HttpResponse.json({})));
|
||||
});
|
||||
const button = getByText(/Deploy the container/);
|
||||
expect(button).toBeVisible();
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
// TODO: from R8S-730 - enable this test once it passes
|
||||
it.skip('should not display any visible error messages on initial load', async () => {
|
||||
renderComponent();
|
||||
const nameInput = getByLabelText(/name/i, { selector: 'input' });
|
||||
await userEvent.type(nameInput, 'name');
|
||||
|
||||
const errors = await screen.findByRole('alert');
|
||||
const imageInput = getByLabelText(/image/i, { selector: 'input' });
|
||||
await userEvent.type(imageInput, 'image');
|
||||
|
||||
// Check that no error messages (role="alert") are visible
|
||||
expect(errors).not.toBeInTheDocument();
|
||||
});
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
|
||||
|
||||
it('submit button should be disabled when name or image is missing', async () => {
|
||||
const { findByText, getByText, getByLabelText } = renderComponent();
|
||||
expect(nameInput).toHaveValue('name');
|
||||
await userEvent.clear(nameInput);
|
||||
|
||||
await expect(findByText(/Azure settings/)).resolves.toBeVisible();
|
||||
|
||||
const button = getByText(/Deploy the container/);
|
||||
expect(button).toBeVisible();
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
const nameInput = getByLabelText(/name/i, { selector: 'input' });
|
||||
await userEvent.type(nameInput, 'name');
|
||||
|
||||
const imageInput = getByLabelText(/image/i, { selector: 'input' });
|
||||
await userEvent.type(imageInput, 'image');
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
|
||||
|
||||
expect(nameInput).toHaveValue('name');
|
||||
await userEvent.clear(nameInput);
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
|
||||
});
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { Formik } from 'formik';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { ContainerInstanceFormValues } from '@/react/azure/types';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { AccessControlForm } from '@/react/portainer/access-control/AccessControlForm';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input, Select } from '@@/form-components/Input';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
|
||||
import { validationSchema } from './CreateContainerInstanceForm.validation';
|
||||
import { PortsMappingField } from './PortsMappingField';
|
||||
import { useFormState, useLoadFormState } from './useLoadFormState';
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
getSubscriptionResourceGroups,
|
||||
} from './utils';
|
||||
import { useCreateInstanceMutation } from './useCreateInstanceMutation';
|
||||
import { CreateContainerInstanceInnerForm } from './CreateContainerInstanceInnerForm';
|
||||
|
||||
export function CreateContainerInstanceForm({
|
||||
defaultValues,
|
||||
@@ -49,15 +61,161 @@ export function CreateContainerInstanceForm({
|
||||
validateOnChange
|
||||
enableReinitialize
|
||||
>
|
||||
{(formikProps) => (
|
||||
<CreateContainerInstanceInnerForm
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...formikProps}
|
||||
subscriptionOptions={subscriptionOptions}
|
||||
environmentId={environmentId}
|
||||
resourceGroups={resourceGroups}
|
||||
providers={providers}
|
||||
/>
|
||||
{({
|
||||
errors,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
values,
|
||||
setFieldValue,
|
||||
}) => (
|
||||
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
|
||||
<FormSectionTitle>Azure settings</FormSectionTitle>
|
||||
<FormControl
|
||||
label="Subscription"
|
||||
inputId="subscription-input"
|
||||
errors={errors.subscription}
|
||||
>
|
||||
<Field
|
||||
name="subscription"
|
||||
as={Select}
|
||||
id="subscription-input"
|
||||
options={subscriptionOptions}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Resource group"
|
||||
inputId="resourceGroup-input"
|
||||
errors={errors.resourceGroup}
|
||||
>
|
||||
<Field
|
||||
name="resourceGroup"
|
||||
as={Select}
|
||||
id="resourceGroup-input"
|
||||
options={getSubscriptionResourceGroups(
|
||||
values.subscription,
|
||||
resourceGroups
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Location"
|
||||
inputId="location-input"
|
||||
errors={errors.location}
|
||||
>
|
||||
<Field
|
||||
name="location"
|
||||
as={Select}
|
||||
id="location-input"
|
||||
options={getSubscriptionLocations(values.subscription, providers)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormSectionTitle>Container configuration</FormSectionTitle>
|
||||
|
||||
<FormControl label="Name" inputId="name-input" errors={errors.name}>
|
||||
<Field
|
||||
name="name"
|
||||
as={Input}
|
||||
id="name-input"
|
||||
placeholder="e.g. myContainer"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Image"
|
||||
inputId="image-input"
|
||||
errors={errors.image}
|
||||
>
|
||||
<Field
|
||||
name="image"
|
||||
as={Input}
|
||||
id="image-input"
|
||||
placeholder="e.g. nginx:alpine"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="OS" inputId="os-input" errors={errors.os}>
|
||||
<Field
|
||||
name="os"
|
||||
as={Select}
|
||||
id="os-input"
|
||||
options={[
|
||||
{ label: 'Linux', value: 'Linux' },
|
||||
{ label: 'Windows', value: 'Windows' },
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<PortsMappingField
|
||||
value={values.ports}
|
||||
onChange={(value) => setFieldValue('ports', value)}
|
||||
errors={errors.ports}
|
||||
/>
|
||||
|
||||
<EnvironmentVariablesPanel
|
||||
values={values.env}
|
||||
onChange={(env) => setFieldValue('env', env)}
|
||||
errors={errors.env}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 small text-muted">
|
||||
This will automatically deploy a container with a public IP
|
||||
address
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormSectionTitle>Container Resources</FormSectionTitle>
|
||||
|
||||
<FormControl label="CPU" inputId="cpu-input" errors={errors.cpu}>
|
||||
<Field
|
||||
name="cpu"
|
||||
as={Input}
|
||||
id="cpu-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Memory"
|
||||
inputId="cpu-input"
|
||||
errors={errors.memory}
|
||||
>
|
||||
<Field
|
||||
name="memory"
|
||||
as={Input}
|
||||
id="memory-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<AccessControlForm
|
||||
formNamespace="accessControl"
|
||||
onChange={(values) => setFieldValue('accessControl', values)}
|
||||
values={values.accessControl}
|
||||
errors={errors.accessControl}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
disabled={!isValid}
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Deployment in progress..."
|
||||
icon={Plus}
|
||||
data-cy="aci-create-button"
|
||||
>
|
||||
Deploy the container
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { Field, Form, FormikProps } from 'formik';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import {
|
||||
ContainerInstanceFormValues,
|
||||
ProviderViewModel,
|
||||
ResourceGroup,
|
||||
} from '@/react/azure/types';
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
getSubscriptionResourceGroups,
|
||||
} from '@/react/azure/container-instances/CreateView/utils';
|
||||
import { PortsMappingField } from '@/react/azure/container-instances/CreateView/PortsMappingField';
|
||||
import { AccessControlForm } from '@/react/portainer/access-control';
|
||||
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input, Select } from '@@/form-components/Input';
|
||||
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { Option } from '@@/form-components/PortainerSelect';
|
||||
|
||||
type Props = FormikProps<ContainerInstanceFormValues> & {
|
||||
subscriptionOptions: Option<string>[];
|
||||
environmentId: number;
|
||||
resourceGroups: Record<string, ResourceGroup[]>;
|
||||
providers: Record<string, ProviderViewModel | undefined>;
|
||||
};
|
||||
|
||||
export function CreateContainerInstanceInnerForm({
|
||||
errors,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
values,
|
||||
setFieldValue,
|
||||
environmentId,
|
||||
subscriptionOptions,
|
||||
resourceGroups,
|
||||
providers,
|
||||
}: Props) {
|
||||
return (
|
||||
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
|
||||
<FormSectionTitle>Azure settings</FormSectionTitle>
|
||||
<FormControl
|
||||
label="Subscription"
|
||||
inputId="subscription-input"
|
||||
errors={errors.subscription}
|
||||
>
|
||||
<Field
|
||||
name="subscription"
|
||||
as={Select}
|
||||
id="subscription-input"
|
||||
options={subscriptionOptions}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Resource group"
|
||||
inputId="resourceGroup-input"
|
||||
errors={errors.resourceGroup}
|
||||
>
|
||||
<Field
|
||||
name="resourceGroup"
|
||||
as={Select}
|
||||
id="resourceGroup-input"
|
||||
options={getSubscriptionResourceGroups(
|
||||
values.subscription,
|
||||
resourceGroups
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Location"
|
||||
inputId="location-input"
|
||||
errors={errors.location}
|
||||
>
|
||||
<Field
|
||||
name="location"
|
||||
as={Select}
|
||||
id="location-input"
|
||||
options={getSubscriptionLocations(values.subscription, providers)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormSectionTitle>Container configuration</FormSectionTitle>
|
||||
|
||||
<FormControl label="Name" inputId="name-input" errors={errors.name}>
|
||||
<Field
|
||||
name="name"
|
||||
as={Input}
|
||||
id="name-input"
|
||||
placeholder="e.g. myContainer"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Image" inputId="image-input" errors={errors.image}>
|
||||
<Field
|
||||
name="image"
|
||||
as={Input}
|
||||
id="image-input"
|
||||
placeholder="e.g. nginx:alpine"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="OS" inputId="os-input" errors={errors.os}>
|
||||
<Field
|
||||
name="os"
|
||||
as={Select}
|
||||
id="os-input"
|
||||
options={[
|
||||
{ label: 'Linux', value: 'Linux' },
|
||||
{ label: 'Windows', value: 'Windows' },
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<PortsMappingField
|
||||
value={values.ports}
|
||||
onChange={(value) => setFieldValue('ports', value)}
|
||||
errors={errors.ports}
|
||||
/>
|
||||
|
||||
<EnvironmentVariablesPanel
|
||||
values={values.env}
|
||||
onChange={(env) => setFieldValue('env', env)}
|
||||
errors={errors.env}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 small text-muted">
|
||||
This will automatically deploy a container with a public IP address
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormSectionTitle>Container Resources</FormSectionTitle>
|
||||
|
||||
<FormControl label="CPU" inputId="cpu-input" errors={errors.cpu}>
|
||||
<Field
|
||||
name="cpu"
|
||||
as={Input}
|
||||
id="cpu-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Memory" inputId="cpu-input" errors={errors.memory}>
|
||||
<Field
|
||||
name="memory"
|
||||
as={Input}
|
||||
id="memory-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<AccessControlForm
|
||||
formNamespace="accessControl"
|
||||
onChange={(values) => setFieldValue('accessControl', values)}
|
||||
values={values.accessControl}
|
||||
errors={errors.accessControl}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
disabled={!isValid}
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Deployment in progress..."
|
||||
icon={Plus}
|
||||
data-cy="aci-create-button"
|
||||
>
|
||||
Deploy the container
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,6 @@ export type SwarmCreatePayload =
|
||||
git: GitFormModel;
|
||||
relativePathSettings?: RelativePathModel;
|
||||
fromAppTemplate?: boolean;
|
||||
webhook?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -109,7 +108,6 @@ type StandaloneCreatePayload =
|
||||
git: GitFormModel;
|
||||
relativePathSettings?: RelativePathModel;
|
||||
fromAppTemplate?: boolean;
|
||||
webhook?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -128,7 +126,6 @@ type KubernetesCreatePayload =
|
||||
payload: KubernetesBasePayload & {
|
||||
git: GitFormModel;
|
||||
relativePathSettings?: RelativePathModel;
|
||||
webhook?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -201,10 +198,7 @@ function createSwarmStack({ method, payload }: SwarmCreatePayload) {
|
||||
filesystemPath: payload.relativePathSettings?.FilesystemPath,
|
||||
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
|
||||
tlsSkipVerify: payload.git.TLSSkipVerify,
|
||||
autoUpdate: transformAutoUpdateViewModel(
|
||||
payload.git.AutoUpdate,
|
||||
payload.webhook
|
||||
),
|
||||
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
|
||||
environmentId: payload.environmentId,
|
||||
swarmID: payload.swarmId,
|
||||
additionalFiles: payload.git.AdditionalFiles,
|
||||
@@ -252,10 +246,7 @@ function createStandaloneStack({ method, payload }: StandaloneCreatePayload) {
|
||||
filesystemPath: payload.relativePathSettings?.FilesystemPath,
|
||||
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
|
||||
tlsSkipVerify: payload.git.TLSSkipVerify,
|
||||
autoUpdate: transformAutoUpdateViewModel(
|
||||
payload.git.AutoUpdate,
|
||||
payload.webhook
|
||||
),
|
||||
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
|
||||
environmentId: payload.environmentId,
|
||||
additionalFiles: payload.git.AdditionalFiles,
|
||||
fromAppTemplate: payload.fromAppTemplate,
|
||||
@@ -300,10 +291,7 @@ function createKubernetesStack({ method, payload }: KubernetesCreatePayload) {
|
||||
repositoryGitCredentialId: payload.git.RepositoryGitCredentialID,
|
||||
|
||||
tlsSkipVerify: payload.git.TLSSkipVerify,
|
||||
autoUpdate: transformAutoUpdateViewModel(
|
||||
payload.git.AutoUpdate,
|
||||
payload.webhook
|
||||
),
|
||||
autoUpdate: transformAutoUpdateViewModel(payload.git.AutoUpdate),
|
||||
environmentId: payload.environmentId,
|
||||
additionalFiles: payload.git.AdditionalFiles,
|
||||
composeFormat: payload.composeFormat,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
|
||||
import { Alert } from './Alert';
|
||||
|
||||
@@ -21,21 +21,21 @@ function Template({ text, color, title }: Args) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Success: StoryFn<Args> = Template.bind({});
|
||||
export const Success: Story<Args> = Template.bind({});
|
||||
Success.args = {
|
||||
color: 'success',
|
||||
title: 'Success',
|
||||
text: 'This is a success alert. Very long text, Very long text,Very long text ,Very long text ,Very long text, Very long text',
|
||||
};
|
||||
|
||||
export const Error: StoryFn<Args> = Template.bind({});
|
||||
export const Error: Story<Args> = Template.bind({});
|
||||
Error.args = {
|
||||
color: 'error',
|
||||
title: 'Error',
|
||||
text: 'This is an error alert',
|
||||
};
|
||||
|
||||
export const Info: StoryFn<Args> = Template.bind({});
|
||||
export const Info: Story<Args> = Template.bind({});
|
||||
Info.args = {
|
||||
color: 'info',
|
||||
title: 'Info',
|
||||
|
||||
@@ -96,7 +96,7 @@ export function AlertContainer({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'border rounded-xl border-solid [&_ul]:ps-8',
|
||||
'border rounded-lg border-solid [&_ul]:ps-8',
|
||||
'p-3',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Card, Props } from './Card';
|
||||
@@ -14,5 +14,5 @@ function Template({
|
||||
return <Card>{children}</Card>;
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
|
||||
import { Code } from './Code';
|
||||
|
||||
@@ -16,18 +16,18 @@ function Template({ text, showCopyButton }: Args) {
|
||||
return <Code showCopyButton={showCopyButton}>{text}</Code>;
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<Args> = Template.bind({});
|
||||
export const Primary: Story<Args> = Template.bind({});
|
||||
Primary.args = {
|
||||
text: 'curl -X GET http://ultra-sound-money.eth',
|
||||
showCopyButton: true,
|
||||
};
|
||||
|
||||
export const MultiLine: StoryFn<Args> = Template.bind({});
|
||||
export const MultiLine: Story<Args> = Template.bind({});
|
||||
MultiLine.args = {
|
||||
text: 'curl -X\n GET http://example-with-children.crypto',
|
||||
};
|
||||
|
||||
export const MultiLineWithIcon: StoryFn<Args> = Template.bind({});
|
||||
export const MultiLineWithIcon: Story<Args> = Template.bind({});
|
||||
MultiLineWithIcon.args = {
|
||||
text: 'curl -X\n GET http://example-with-children.crypto',
|
||||
showCopyButton: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { List } from 'lucide-react';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
@@ -29,7 +29,7 @@ function Template({ value, icon, type }: StoryProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<StoryProps> = Template.bind({});
|
||||
export const Primary: Story<StoryProps> = Template.bind({});
|
||||
Primary.args = {
|
||||
value: 1,
|
||||
icon: List,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
|
||||
import { DetailsTable } from './DetailsTable';
|
||||
import { DetailsRow } from './DetailsRow';
|
||||
@@ -24,7 +24,7 @@ function Template({ key1, val1, key2, val2 }: Args) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: StoryFn<Args> = Template.bind({});
|
||||
export const Default: Story<Args> = Template.bind({});
|
||||
Default.args = {
|
||||
key1: 'Name',
|
||||
val1: 'My Cool App',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { StoryFn, Meta } from '@storybook/react';
|
||||
import { Story, Meta } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { InlineLoader, Props } from './InlineLoader';
|
||||
@@ -12,7 +12,7 @@ function Template({ className, children }: PropsWithChildren<Props>) {
|
||||
return <InlineLoader className={className}>{children}</InlineLoader>;
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {
|
||||
className: 'test-class',
|
||||
children: 'Loading...',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
|
||||
import { InsightsBox, Props } from './InsightsBox';
|
||||
|
||||
@@ -11,7 +11,7 @@ function Template({ header, content }: Props) {
|
||||
return <InsightsBox header={header} content={content} />;
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<Props> = Template.bind({});
|
||||
export const Primary: Story<Props> = Template.bind({});
|
||||
Primary.args = {
|
||||
header: 'Insights box header',
|
||||
content: 'This is the content of the insights box',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { ComponentMeta, Story } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { NavTabs, type Option } from './NavTabs';
|
||||
@@ -6,7 +6,7 @@ import { NavTabs, type Option } from './NavTabs';
|
||||
export default {
|
||||
title: 'Components/NavTabs',
|
||||
component: NavTabs,
|
||||
} as Meta<typeof NavTabs>;
|
||||
} as ComponentMeta<typeof NavTabs>;
|
||||
|
||||
type Args = {
|
||||
options: Option[];
|
||||
@@ -26,7 +26,7 @@ function Template({ options = [] }: Args) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Example: StoryFn<Args> = Template.bind({});
|
||||
export const Example: Story<Args> = Template.bind({});
|
||||
Example.args = {
|
||||
options: [
|
||||
{ children: 'Content 1', id: 'option1', label: 'Option 1' },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
@@ -39,7 +39,7 @@ function Template({ title }: StoryProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<StoryProps> = Template.bind({});
|
||||
export const Primary: Story<StoryProps> = Template.bind({});
|
||||
Primary.args = {
|
||||
title: 'Container details',
|
||||
};
|
||||
|
||||
@@ -8,6 +8,22 @@ import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { HeaderContainer } from './HeaderContainer';
|
||||
import { HeaderTitle } from './HeaderTitle';
|
||||
|
||||
test('should not render without a wrapping HeaderContainer', async () => {
|
||||
const consoleErrorFn = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => vi.fn());
|
||||
|
||||
const title = 'title';
|
||||
function renderComponent() {
|
||||
const Wrapped = withTestQueryProvider(HeaderTitle);
|
||||
return render(<Wrapped title={title} />);
|
||||
}
|
||||
|
||||
expect(renderComponent).toThrowErrorMatchingSnapshot();
|
||||
|
||||
consoleErrorFn.mockRestore();
|
||||
});
|
||||
|
||||
test('should display a HeaderTitle', async () => {
|
||||
const username = 'username';
|
||||
const user = new UserViewModel({ Username: username });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
@@ -37,7 +37,7 @@ function Template({ title }: StoryProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<StoryProps> = Template.bind({});
|
||||
export const Primary: Story<StoryProps> = Template.bind({});
|
||||
Primary.args = {
|
||||
title: 'Container details',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`should not render without a wrapping HeaderContainer 1`] = `[Error: Should be nested inside a HeaderContainer component]`;
|
||||
@@ -15,8 +15,7 @@ export function StickyFooter({
|
||||
<div
|
||||
className={clsx(
|
||||
styles.actionBar,
|
||||
// The sticky footer should be below the modal overlay `Modal.tsx` and react select menu `ReactSelect.css` (z-50)
|
||||
'fixed bottom-0 right-0 z-10 h-16',
|
||||
'fixed bottom-0 right-0 z-50 h-16',
|
||||
'flex items-center px-6',
|
||||
'bg-[var(--bg-widget-color)] border-t border-[var(--border-widget-color)]',
|
||||
'shadow-[0_-2px_10px_rgba(0,0,0,0.1)]',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { TextTip } from './TextTip';
|
||||
@@ -14,7 +14,7 @@ function Template({
|
||||
return <TextTip>{children}</TextTip>;
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<PropsWithChildren<unknown>> = Template.bind({});
|
||||
export const Primary: Story<PropsWithChildren<unknown>> = Template.bind({});
|
||||
Primary.args = {
|
||||
children: 'This is a text tip with children',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
|
||||
import { Tooltip, Props } from './Tooltip';
|
||||
|
||||
@@ -16,7 +16,7 @@ function Template({ message, position }: JSX.IntrinsicAttributes & Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<Props> = Template.bind({});
|
||||
export const Primary: Story<Props> = Template.bind({});
|
||||
Primary.args = {
|
||||
message: 'Tooltip example',
|
||||
position: 'bottom',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
|
||||
import { ViewLoading } from './ViewLoading';
|
||||
|
||||
@@ -15,7 +15,7 @@ function Template({ message }: Args) {
|
||||
return <ViewLoading message={message} />;
|
||||
}
|
||||
|
||||
export const Example: StoryFn<Args> = Template.bind({});
|
||||
export const Example: Story<Args> = Template.bind({});
|
||||
Example.args = {
|
||||
message: 'Loading...',
|
||||
};
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { Box, Settings, Users } from 'lucide-react';
|
||||
import {
|
||||
ReactStateDeclaration,
|
||||
UIRouter,
|
||||
UIRouterReact,
|
||||
UIView,
|
||||
hashLocationPlugin,
|
||||
servicesPlugin,
|
||||
} from '@uirouter/react';
|
||||
|
||||
import { WidgetTabs, Tab } from './WidgetTabs';
|
||||
|
||||
// Create a UIRouter instance with a dummy state so `Link to="."` works
|
||||
function withRouter(Story: () => JSX.Element) {
|
||||
const router = new UIRouterReact();
|
||||
router.plugin(servicesPlugin);
|
||||
router.plugin(hashLocationPlugin);
|
||||
|
||||
// Register a dummy state that renders the Story
|
||||
const storyState: ReactStateDeclaration = {
|
||||
name: 'storybook',
|
||||
url: '/?tab',
|
||||
component: Story,
|
||||
};
|
||||
router.stateRegistry.register(storyState);
|
||||
|
||||
// Set initial state (UIRouter component calls start() automatically)
|
||||
router.urlService.rules.initial({ state: 'storybook' });
|
||||
router.urlService.rules.otherwise({ state: 'storybook' });
|
||||
|
||||
return (
|
||||
<UIRouter router={router}>
|
||||
<UIView />
|
||||
</UIRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof WidgetTabs> = {
|
||||
title: 'Components/Widget/WidgetTabs',
|
||||
component: WidgetTabs,
|
||||
decorators: [withRouter],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const defaultTabs: Tab[] = [
|
||||
{
|
||||
name: 'Overview',
|
||||
widget: <div>Overview content</div>,
|
||||
selectedTabParam: 'overview',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
widget: <div>Settings content</div>,
|
||||
selectedTabParam: 'settings',
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
widget: <div>Users content</div>,
|
||||
selectedTabParam: 'users',
|
||||
},
|
||||
];
|
||||
|
||||
const tabsWithIcons: Tab[] = [
|
||||
{
|
||||
name: 'Overview',
|
||||
icon: Box,
|
||||
widget: <div>Overview content</div>,
|
||||
selectedTabParam: 'overview',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
icon: Settings,
|
||||
widget: <div>Settings content</div>,
|
||||
selectedTabParam: 'settings',
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
icon: Users,
|
||||
widget: <div>Users content</div>,
|
||||
selectedTabParam: 'users',
|
||||
},
|
||||
];
|
||||
|
||||
interface StoryArgs {
|
||||
currentTabIndex: number;
|
||||
tabs: Tab[];
|
||||
useContainer?: boolean;
|
||||
}
|
||||
|
||||
function Template({ currentTabIndex, tabs, useContainer }: StoryArgs) {
|
||||
return (
|
||||
<WidgetTabs
|
||||
currentTabIndex={currentTabIndex}
|
||||
tabs={tabs}
|
||||
useContainer={useContainer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const Default: StoryFn<StoryArgs> = Template.bind({});
|
||||
Default.args = {
|
||||
currentTabIndex: 0,
|
||||
tabs: defaultTabs,
|
||||
};
|
||||
|
||||
export const WithIcons: StoryFn<StoryArgs> = Template.bind({});
|
||||
WithIcons.args = {
|
||||
currentTabIndex: 0,
|
||||
tabs: tabsWithIcons,
|
||||
};
|
||||
|
||||
export const SecondTabSelected: StoryFn<StoryArgs> = Template.bind({});
|
||||
SecondTabSelected.args = {
|
||||
currentTabIndex: 1,
|
||||
tabs: tabsWithIcons,
|
||||
};
|
||||
|
||||
export const WithoutContainer: StoryFn<StoryArgs> = Template.bind({});
|
||||
WithoutContainer.args = {
|
||||
currentTabIndex: 0,
|
||||
tabs: tabsWithIcons,
|
||||
useContainer: false,
|
||||
};
|
||||
|
||||
export const TwoTabs: StoryFn<StoryArgs> = Template.bind({});
|
||||
TwoTabs.args = {
|
||||
currentTabIndex: 0,
|
||||
tabs: [
|
||||
{
|
||||
name: 'Tab 1',
|
||||
widget: <div>Tab 1 content</div>,
|
||||
selectedTabParam: 'tab1',
|
||||
},
|
||||
{
|
||||
name: 'Tab 2',
|
||||
widget: <div>Tab 2 content</div>,
|
||||
selectedTabParam: 'tab2',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,173 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Layers } from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { findSelectedTabIndex, Tab, WidgetTabs } from './WidgetTabs';
|
||||
|
||||
// Mock Link component to avoid ui-router relative state resolution in tests
|
||||
vi.mock('@@/Link', () => ({
|
||||
Link: ({
|
||||
children,
|
||||
'data-cy': dataCy,
|
||||
'aria-current': ariaCurrent,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
'data-cy'?: string;
|
||||
'aria-current'?: 'page' | undefined;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a
|
||||
data-cy={dataCy}
|
||||
aria-current={ariaCurrent}
|
||||
className={className}
|
||||
href="/"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockTabs: Tab[] = [
|
||||
{
|
||||
name: 'Overview',
|
||||
widget: <div>Overview content</div>,
|
||||
selectedTabParam: 'overview',
|
||||
},
|
||||
{
|
||||
name: 'Details',
|
||||
widget: <div>Details content</div>,
|
||||
selectedTabParam: 'details',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
widget: <div>Settings content</div>,
|
||||
selectedTabParam: 'settings',
|
||||
},
|
||||
];
|
||||
|
||||
function renderWidgetTabs(
|
||||
props: Partial<React.ComponentProps<typeof WidgetTabs>> = {}
|
||||
) {
|
||||
const defaultProps = {
|
||||
currentTabIndex: 0,
|
||||
tabs: mockTabs,
|
||||
};
|
||||
|
||||
return render(<WidgetTabs {...defaultProps} {...props} />);
|
||||
}
|
||||
|
||||
describe('WidgetTabs', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders all tabs and highlights the current tab', () => {
|
||||
renderWidgetTabs({ currentTabIndex: 1 });
|
||||
|
||||
// All tabs should be visible
|
||||
expect(screen.getByRole('link', { name: 'Overview' })).toBeVisible();
|
||||
expect(screen.getByRole('link', { name: 'Details' })).toBeVisible();
|
||||
expect(screen.getByRole('link', { name: 'Settings' })).toBeVisible();
|
||||
|
||||
// Only the selected tab should have aria-current="page"
|
||||
expect(screen.getByRole('link', { name: 'Details' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page'
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'Overview' })
|
||||
).not.toHaveAttribute('aria-current');
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'Settings' })
|
||||
).not.toHaveAttribute('aria-current');
|
||||
});
|
||||
|
||||
it('renders tab icons when provided', () => {
|
||||
const tabsWithIcons: Tab[] = [
|
||||
{
|
||||
name: 'Tab 1',
|
||||
icon: Layers,
|
||||
widget: <div />,
|
||||
selectedTabParam: 'tab1',
|
||||
},
|
||||
{ name: 'Tab 2', widget: <div />, selectedTabParam: 'tab2' },
|
||||
];
|
||||
|
||||
renderWidgetTabs({ tabs: tabsWithIcons, currentTabIndex: 0 });
|
||||
|
||||
// Tab with icon should contain an svg (lucide icon)
|
||||
const tab1 = screen.getByRole('link', { name: 'Tab 1' });
|
||||
expect(tab1.querySelector('svg')).toBeVisible();
|
||||
|
||||
// Tab without icon should not contain an svg
|
||||
const tab2 = screen.getByRole('link', { name: 'Tab 2' });
|
||||
expect(tab2.querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders without container when useContainer is false', () => {
|
||||
const { container } = renderWidgetTabs({ useContainer: false });
|
||||
|
||||
// Should not have the bootstrap row/col wrapper
|
||||
expect(container.querySelector('.row')).toBeNull();
|
||||
expect(container.querySelector('.col-sm-12')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders with container wrapper by default', () => {
|
||||
const { container } = renderWidgetTabs();
|
||||
|
||||
// Should have the bootstrap row/col wrapper
|
||||
expect(container.querySelector('.row')).toBeVisible();
|
||||
expect(container.querySelector('.col-sm-12')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws an error when any tab has an invalid URL-encodable param value', () => {
|
||||
// Tabs with characters that change when URL-encoded
|
||||
const invalidTabs: Tab[] = [
|
||||
{
|
||||
name: 'Tab A',
|
||||
widget: <div />,
|
||||
selectedTabParam: 'param with spaces',
|
||||
},
|
||||
{ name: 'Tab B', widget: <div />, selectedTabParam: 'good-param' },
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
renderWidgetTabs({ tabs: invalidTabs, currentTabIndex: 1 })
|
||||
).toThrow('Invalid query param value for tab');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('has accessible navigation landmark with label', () => {
|
||||
renderWidgetTabs();
|
||||
|
||||
const nav = screen.getByRole('navigation', {
|
||||
name: 'Section navigation',
|
||||
});
|
||||
expect(nav).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSelectedTabIndex', () => {
|
||||
it('returns the correct index when tab param matches', () => {
|
||||
const result = findSelectedTabIndex({ tab: 'details' }, mockTabs);
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 0 when tab param does not match any tab', () => {
|
||||
const result = findSelectedTabIndex({ tab: 'nonexistent' }, mockTabs);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when params.tab is undefined', () => {
|
||||
const result = findSelectedTabIndex({}, mockTabs);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the index of the first tab when params.tab matches first tab', () => {
|
||||
const result = findSelectedTabIndex({ tab: 'overview' }, mockTabs);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -15,76 +15,54 @@ export interface Tab {
|
||||
interface Props {
|
||||
currentTabIndex: number;
|
||||
tabs: Tab[];
|
||||
useContainer?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function WidgetTabs({
|
||||
currentTabIndex,
|
||||
tabs,
|
||||
useContainer = true,
|
||||
ariaLabel = 'Section navigation',
|
||||
}: Props) {
|
||||
// https://www.figma.com/file/g5TUMngrblkXM7NHSyQsD1/New-UI?type=design&node-id=148-2676&mode=design&t=JKyBWBupeC5WADk6-0
|
||||
export function WidgetTabs({ currentTabIndex, tabs }: Props) {
|
||||
// ensure that the selectedTab param is always valid
|
||||
const invalidQueryParamValue = tabs.some(
|
||||
const invalidQueryParamValue = tabs.every(
|
||||
(tab) => encodeURIComponent(tab.selectedTabParam) !== tab.selectedTabParam
|
||||
);
|
||||
|
||||
if (invalidQueryParamValue) {
|
||||
throw new Error('Invalid query param value for tab');
|
||||
}
|
||||
|
||||
const tabsComponent = (
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={clsx(
|
||||
'max-w-fit overflow-hidden rounded-xl',
|
||||
'bg-[var(--bg-widget-color)] border border-solid border-[var(--border-widget)]'
|
||||
)}
|
||||
>
|
||||
{/* additional div, so that the scrollbar doesn't overlap with rounded corners of the nav parent */}
|
||||
<div className="flex items-center gap-1 p-1 overflow-x-auto">
|
||||
{tabs.map(({ name, icon }, index) => (
|
||||
<Link
|
||||
to="."
|
||||
params={{ tab: tabs[index].selectedTabParam }}
|
||||
key={index}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 px-4 py-2 rounded-lg',
|
||||
'hover:no-underline focus:no-underline text-gray-7 th-highcontrast:text-white th-dark:text-gray-6',
|
||||
'transition-colors duration-200',
|
||||
{
|
||||
'border-inherit !bg-graphite-50 !text-graphite-900 hover:text-graphite-900 th-dark:!bg-graphite-600 th-dark:!text-white th-highcontrast:!bg-white th-highcontrast:!text-black':
|
||||
currentTabIndex === index,
|
||||
},
|
||||
{
|
||||
'bg-transparent hover:bg-graphite-50 th-dark:hover:bg-graphite-600 th-highcontrast:hover:bg-white hover:text-gray-7 th-dark:hover:text-gray-6 th-highcontrast:hover:text-black':
|
||||
currentTabIndex !== index,
|
||||
}
|
||||
)}
|
||||
data-cy={`tab-${index}`}
|
||||
aria-current={currentTabIndex === index ? 'page' : undefined}
|
||||
>
|
||||
{icon && <Icon icon={icon} />}
|
||||
{name}
|
||||
</Link>
|
||||
))}
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12 !mb-0">
|
||||
<div className="pl-2">
|
||||
{tabs.map(({ name, icon }, index) => (
|
||||
<Link
|
||||
to="."
|
||||
params={{ tab: tabs[index].selectedTabParam }}
|
||||
key={index}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-2 border-0 border-b-2 border-solid bg-transparent px-4 py-2 hover:no-underline',
|
||||
{
|
||||
'border-blue-8 text-blue-8 th-highcontrast:border-blue-6 th-highcontrast:text-blue-6 th-dark:border-blue-6 th-dark:text-blue-6':
|
||||
currentTabIndex === index,
|
||||
'border-transparent text-gray-7 hover:text-gray-8 th-highcontrast:text-gray-6 hover:th-highcontrast:text-gray-5 th-dark:text-gray-6 hover:th-dark:text-gray-5':
|
||||
currentTabIndex !== index,
|
||||
}
|
||||
)}
|
||||
data-cy={`tab-${index}`}
|
||||
>
|
||||
{icon && <Icon icon={icon} />}
|
||||
{name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (useContainer) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">{tabsComponent}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return tabsComponent;
|
||||
}
|
||||
|
||||
// findSelectedTabIndex returns the index of the tab, or 0 if none is selected
|
||||
export function findSelectedTabIndex(params: RawParams, tabs: Tab[]) {
|
||||
export function findSelectedTabIndex(
|
||||
{ params }: { params: RawParams },
|
||||
tabs: Tab[]
|
||||
) {
|
||||
const selectedTabParam = params.tab || tabs[0].selectedTabParam;
|
||||
const currentTabIndex = tabs.findIndex(
|
||||
(tab) => tab.selectedTabParam === selectedTabParam
|
||||
@@ -97,7 +75,7 @@ export function findSelectedTabIndex(params: RawParams, tabs: Tab[]) {
|
||||
|
||||
export function useCurrentTabIndex(tabs: Tab[]) {
|
||||
const params = useCurrentStateAndParams();
|
||||
const currentTabIndex = findSelectedTabIndex(params.params, tabs);
|
||||
const currentTabIndex = findSelectedTabIndex(params, tabs);
|
||||
|
||||
return currentTabIndex;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
|
||||
import { AddButton } from './AddButton';
|
||||
|
||||
@@ -15,7 +15,7 @@ function Template({ label }: Args) {
|
||||
return <AddButton data-cy="add-">{label}</AddButton>;
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<Args> = Template.bind({});
|
||||
export const Primary: Story<Args> = Template.bind({});
|
||||
Primary.args = {
|
||||
label: 'Create new container',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
|
||||
@@ -85,7 +85,7 @@ function Template({
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {
|
||||
color: 'primary',
|
||||
size: 'small',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Play, RefreshCw, Square, Trash2 } from 'lucide-react';
|
||||
|
||||
@@ -45,7 +45,7 @@ function Template({
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {
|
||||
size: 'small',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { CopyButton, Props } from './CopyButton';
|
||||
@@ -24,13 +24,13 @@ function Template({
|
||||
);
|
||||
}
|
||||
|
||||
export const Primary: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {
|
||||
children: 'Copy',
|
||||
copyText: 'this will be copied to clipboard',
|
||||
};
|
||||
|
||||
export const NoCopyText: StoryFn<PropsWithChildren<Props>> = Template.bind({});
|
||||
export const NoCopyText: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
NoCopyText.args = {
|
||||
children: 'Copy without copied text',
|
||||
copyText: 'clipboard override',
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
font-size: 16px;
|
||||
border-top-left-radius: 12px;
|
||||
border-top-right-radius: 12px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -100,8 +100,8 @@
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--border-datatable-top-color);
|
||||
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
|
||||
import { FormSection } from './FormSection';
|
||||
|
||||
@@ -25,7 +25,7 @@ const exampleContent = `Content
|
||||
Nullam nec nibh maximus, consequat quam sed, dapibus purus. Donec facilisis commodo mi, in commodo augue molestie sed.
|
||||
`;
|
||||
|
||||
export const Example: StoryFn<Args> = Template.bind({});
|
||||
export const Example: Story<Args> = Template.bind({});
|
||||
Example.args = {
|
||||
title: 'title',
|
||||
content: exampleContent,
|
||||
|
||||
@@ -52,7 +52,7 @@ export function FormSection({
|
||||
</FormSectionTitle>
|
||||
{/* col-sm-12 in the title has a 'float: left' style - 'clear-both' makes sure it doesn't get in the way of the next div */}
|
||||
{/* https://stackoverflow.com/questions/7759837/put-divs-below-floatleft-divs */}
|
||||
<div className="clear-both">{isExpanded && children}</div>
|
||||
{isExpanded && <div className="clear-both">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { FormSectionTitle } from './FormSectionTitle';
|
||||
@@ -14,7 +14,7 @@ function Template({
|
||||
return <FormSectionTitle>{children}</FormSectionTitle>;
|
||||
}
|
||||
|
||||
export const Example: StoryFn<PropsWithChildren<unknown>> = Template.bind({});
|
||||
export const Example: Story<PropsWithChildren<unknown>> = Template.bind({});
|
||||
Example.args = {
|
||||
children: 'This is a title with children',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Input } from './Input';
|
||||
@@ -27,7 +27,7 @@ export function TextField({ disabled }: Args) {
|
||||
);
|
||||
}
|
||||
|
||||
export const DisabledTextField: StoryFn<Args> = TextField.bind({});
|
||||
export const DisabledTextField: Story<Args> = TextField.bind({});
|
||||
DisabledTextField.args = {
|
||||
disabled: true,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user