Compare commits
39 Commits
2.33.4
...
fix/EE-499
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59141ec37d | ||
|
|
2216d2cdd2 | ||
|
|
70c9172112 | ||
|
|
043ec008fe | ||
|
|
a3b8b0d58e | ||
|
|
fc09e5574f | ||
|
|
1dcdce9feb | ||
|
|
12ea9fa404 | ||
|
|
38f09fc8ad | ||
|
|
c1c4dd190d | ||
|
|
674a98e432 | ||
|
|
da7351cec7 | ||
|
|
c9562d1252 | ||
|
|
cae257ddfc | ||
|
|
7a063cb2fa | ||
|
|
d2c967282e | ||
|
|
a7cad6fd09 | ||
|
|
d11ae40822 | ||
|
|
503ef6b415 | ||
|
|
b8b69ce116 | ||
|
|
54fb7242c3 | ||
|
|
2887c11c93 | ||
|
|
00b64beed7 | ||
|
|
1199fe19ec | ||
|
|
6d0f473f76 | ||
|
|
6b804152fe | ||
|
|
9e2849dd10 | ||
|
|
eeff16a300 | ||
|
|
de05fa869d | ||
|
|
81ae01d3a2 | ||
|
|
4e0d264bc4 | ||
|
|
3f2220e340 | ||
|
|
b67c28f128 | ||
|
|
3a3ec0c50c | ||
|
|
26f975f1d5 | ||
|
|
00f72a80f2 | ||
|
|
fc7d226f38 | ||
|
|
995579fda7 | ||
|
|
0fd59eb6a8 |
@@ -83,6 +83,7 @@ overrides:
|
||||
'newlines-between': 'always',
|
||||
},
|
||||
]
|
||||
no-plusplus: off
|
||||
func-style: [error, 'declaration']
|
||||
import/prefer-default-export: off
|
||||
no-use-before-define: ['error', { functions: false }]
|
||||
|
||||
@@ -2,12 +2,67 @@ package migrator
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (m *Migrator) migrateDBVersionToDB80() error {
|
||||
return m.updateEdgeStackStatusForDB80()
|
||||
if err := m.updateEdgeStackStatusForDB80(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.updateExistingEndpointsToNotDetectMetricsAPIForDB80(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.updateExistingEndpointsToNotDetectStorageAPIForDB80(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) updateExistingEndpointsToNotDetectMetricsAPIForDB80() error {
|
||||
log.Info().Msg("updating existing endpoints to not detect metrics API for existing endpoints (k8s)")
|
||||
|
||||
endpoints, err := m.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpointutils.IsKubernetesEndpoint(&endpoint) {
|
||||
endpoint.Kubernetes.Flags.IsServerMetricsDetected = true
|
||||
err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) updateExistingEndpointsToNotDetectStorageAPIForDB80() error {
|
||||
log.Info().Msg("updating existing endpoints to not detect metrics API for existing endpoints (k8s)")
|
||||
|
||||
endpoints, err := m.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpointutils.IsKubernetesEndpoint(&endpoint) {
|
||||
endpoint.Kubernetes.Flags.IsServerStorageDetected = true
|
||||
err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) updateEdgeStackStatusForDB80() error {
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
"UseLoadBalancer": false,
|
||||
"UseServerMetrics": false
|
||||
},
|
||||
"Flags": {
|
||||
"IsServerMetricsDetected": false,
|
||||
"IsServerStorageDetected": false
|
||||
},
|
||||
"Snapshots": []
|
||||
},
|
||||
"LastCheckInDate": 0,
|
||||
|
||||
@@ -127,9 +127,9 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.addUserIntoTeams(user, ldapSettings)
|
||||
err = handler.syncUserTeamsWithLDAPGroups(user, ldapSettings)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("unable to automatically add user into teams")
|
||||
log.Warn().Err(err).Msg("unable to automatically sync user teams with ldap")
|
||||
}
|
||||
|
||||
return handler.writeToken(w, user, false)
|
||||
@@ -150,7 +150,12 @@ func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *p
|
||||
return response.JSON(w, &authenticateResponse{JWT: token})
|
||||
}
|
||||
|
||||
func (handler *Handler) addUserIntoTeams(user *portainer.User, settings *portainer.LDAPSettings) error {
|
||||
func (handler *Handler) syncUserTeamsWithLDAPGroups(user *portainer.User, settings *portainer.LDAPSettings) error {
|
||||
// only sync if there is a group base DN
|
||||
if len(settings.GroupSearchSettings) == 0 || len(settings.GroupSearchSettings[0].GroupBaseDN) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
teams, err := handler.DataStore.Team().Teams()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -66,5 +66,16 @@ func (handler *Handler) edgeJobTasksCollect(w http.ResponseWriter, r *http.Reque
|
||||
return httperror.InternalServerError("Unable to persist Edge job changes in the database", err)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve environment from the database", err)
|
||||
}
|
||||
|
||||
if endpoint.Edge.AsyncMode {
|
||||
return httperror.BadRequest("Async Edge Endpoints are not supported in Portainer CE", nil)
|
||||
}
|
||||
|
||||
handler.ReverseTunnelService.AddEdgeJob(endpointID, edgeJob)
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
@@ -192,7 +192,10 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
stack.NumDeployments = len(relatedEndpointIds)
|
||||
stack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus)
|
||||
|
||||
if versionUpdated {
|
||||
stack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus)
|
||||
}
|
||||
|
||||
err = handler.DataStore.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
||||
if err != nil {
|
||||
|
||||
@@ -55,13 +55,13 @@ const (
|
||||
func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
|
||||
if err != nil {
|
||||
return errors.New("Invalid environment name")
|
||||
return errors.New("invalid environment name")
|
||||
}
|
||||
payload.Name = name
|
||||
|
||||
endpointCreationType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointCreationType", false)
|
||||
if err != nil || endpointCreationType == 0 {
|
||||
return errors.New("Invalid environment type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge Agent environment) or 5 (Local Kubernetes environment)")
|
||||
return errors.New("invalid environment type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge Agent environment) or 5 (Local Kubernetes environment)")
|
||||
}
|
||||
payload.EndpointCreationType = endpointCreationEnum(endpointCreationType)
|
||||
|
||||
@@ -74,7 +74,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||
var tagIDs []portainer.TagID
|
||||
err = request.RetrieveMultiPartFormJSONValue(r, "TagIds", &tagIDs, true)
|
||||
if err != nil {
|
||||
return errors.New("Invalid TagIds parameter")
|
||||
return errors.New("invalid TagIds parameter")
|
||||
}
|
||||
payload.TagIDs = tagIDs
|
||||
if payload.TagIDs == nil {
|
||||
@@ -93,7 +93,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||
if !payload.TLSSkipVerify {
|
||||
caCert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile")
|
||||
if err != nil {
|
||||
return errors.New("Invalid CA certificate file. Ensure that the file is uploaded correctly")
|
||||
return errors.New("invalid CA certificate file. Ensure that the file is uploaded correctly")
|
||||
}
|
||||
payload.TLSCACertFile = caCert
|
||||
}
|
||||
@@ -101,13 +101,13 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||
if !payload.TLSSkipClientVerify {
|
||||
cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile")
|
||||
if err != nil {
|
||||
return errors.New("Invalid certificate file. Ensure that the file is uploaded correctly")
|
||||
return errors.New("invalid certificate file. Ensure that the file is uploaded correctly")
|
||||
}
|
||||
payload.TLSCertFile = cert
|
||||
|
||||
key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile")
|
||||
if err != nil {
|
||||
return errors.New("Invalid key file. Ensure that the file is uploaded correctly")
|
||||
return errors.New("invalid key file. Ensure that the file is uploaded correctly")
|
||||
}
|
||||
payload.TLSKeyFile = key
|
||||
}
|
||||
@@ -117,19 +117,19 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||
case azureEnvironment:
|
||||
azureApplicationID, err := request.RetrieveMultiPartFormValue(r, "AzureApplicationID", false)
|
||||
if err != nil {
|
||||
return errors.New("Invalid Azure application ID")
|
||||
return errors.New("invalid Azure application ID")
|
||||
}
|
||||
payload.AzureApplicationID = azureApplicationID
|
||||
|
||||
azureTenantID, err := request.RetrieveMultiPartFormValue(r, "AzureTenantID", false)
|
||||
if err != nil {
|
||||
return errors.New("Invalid Azure tenant ID")
|
||||
return errors.New("invalid Azure tenant ID")
|
||||
}
|
||||
payload.AzureTenantID = azureTenantID
|
||||
|
||||
azureAuthenticationKey, err := request.RetrieveMultiPartFormValue(r, "AzureAuthenticationKey", false)
|
||||
if err != nil {
|
||||
return errors.New("Invalid Azure authentication key")
|
||||
return errors.New("invalid Azure authentication key")
|
||||
}
|
||||
payload.AzureAuthenticationKey = azureAuthenticationKey
|
||||
|
||||
@@ -146,7 +146,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||
default:
|
||||
endpointURL, err := request.RetrieveMultiPartFormValue(r, "URL", true)
|
||||
if err != nil {
|
||||
return errors.New("Invalid environment URL")
|
||||
return errors.New("invalid environment URL")
|
||||
}
|
||||
payload.URL = endpointURL
|
||||
|
||||
@@ -157,7 +157,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||
gpus := make([]portainer.Pair, 0)
|
||||
err = request.RetrieveMultiPartFormJSONValue(r, "Gpus", &gpus, true)
|
||||
if err != nil {
|
||||
return errors.New("Invalid Gpus parameter")
|
||||
return errors.New("invalid Gpus parameter")
|
||||
}
|
||||
payload.Gpus = gpus
|
||||
|
||||
@@ -195,6 +195,9 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||
// @param AzureAuthenticationKey formData string false "Azure authentication key. Required if environment(endpoint) type is set to 3"
|
||||
// @param TagIDs formData []int false "List of tag identifiers to which this environment(endpoint) is associated"
|
||||
// @param EdgeCheckinInterval formData int false "The check in interval for edge agent (in seconds)"
|
||||
// @param EdgeTunnelServerAddress formData string true "URL or IP address that will be used to establish a reverse tunnel"
|
||||
// @param IsEdgeDevice formData bool false "Is Edge Device"
|
||||
// @param Gpus formData array false "List of GPUs"
|
||||
// @success 200 {object} portainer.Endpoint "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
)
|
||||
|
||||
// @id EndpointDelete
|
||||
@@ -124,6 +125,26 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
if !endpointutils.IsEdgeEndpoint(endpoint) {
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
edgeJobs, err := handler.DataStore.EdgeJob().EdgeJobs()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve edge jobs from the database", err)
|
||||
}
|
||||
|
||||
for idx := range edgeJobs {
|
||||
edgeJob := &edgeJobs[idx]
|
||||
if _, ok := edgeJob.Endpoints[endpoint.ID]; ok {
|
||||
delete(edgeJob.Endpoints, endpoint.ID)
|
||||
err = handler.DataStore.EdgeJob().UpdateEdgeJob(edgeJob.ID, edgeJob)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to update edge job", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
)
|
||||
|
||||
// @id EndpointInspect
|
||||
@@ -51,6 +52,26 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
if endpointutils.IsKubernetesEndpoint(endpoint) {
|
||||
isServerMetricsDetected := endpoint.Kubernetes.Flags.IsServerMetricsDetected
|
||||
if !isServerMetricsDetected && handler.K8sClientFactory != nil {
|
||||
endpointutils.InitialMetricsDetection(
|
||||
endpoint,
|
||||
handler.DataStore.Endpoint(),
|
||||
handler.K8sClientFactory,
|
||||
)
|
||||
}
|
||||
|
||||
isServerStorageDetected := endpoint.Kubernetes.Flags.IsServerStorageDetected
|
||||
if !isServerStorageDetected && handler.K8sClientFactory != nil {
|
||||
endpointutils.InitialStorageDetection(
|
||||
endpoint,
|
||||
handler.DataStore.Endpoint(),
|
||||
handler.K8sClientFactory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, endpoint)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package endpointutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
@@ -116,6 +118,7 @@ func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService datas
|
||||
return
|
||||
}
|
||||
endpoint.Kubernetes.Configuration.UseServerMetrics = true
|
||||
endpoint.Kubernetes.Flags.IsServerMetricsDetected = true
|
||||
err = endpointService.UpdateEndpoint(
|
||||
portainer.EndpointID(endpoint.ID),
|
||||
endpoint,
|
||||
@@ -126,17 +129,21 @@ func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService datas
|
||||
}
|
||||
}
|
||||
|
||||
func InitialStorageDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) {
|
||||
func storageDetect(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) error {
|
||||
cli, err := factory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("unable to create Kubernetes client for initial storage detection")
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
storage, err := cli.GetStorage()
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("unable to fetch storage classes: leaving storage classes disabled")
|
||||
return
|
||||
return err
|
||||
}
|
||||
if len(storage) == 0 {
|
||||
log.Info().Err(err).Msg("zero storage classes found: they may be still building, retrying in 30 seconds")
|
||||
return fmt.Errorf("zero storage classes found: they may be still building, retrying in 30 seconds")
|
||||
}
|
||||
endpoint.Kubernetes.Configuration.StorageClasses = storage
|
||||
err = endpointService.UpdateEndpoint(
|
||||
@@ -145,6 +152,23 @@ func InitialStorageDetection(endpoint *portainer.Endpoint, endpointService datas
|
||||
)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("unable to enable storage class inside the database")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitialStorageDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) {
|
||||
log.Info().Msg("attempting to detect storage classes in the cluster")
|
||||
err := storageDetect(endpoint, endpointService, factory)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
log.Err(err).Msg("error while detecting storage classes")
|
||||
go func() {
|
||||
// Retry after 30 seconds if the initial detection failed.
|
||||
log.Info().Msg("retrying storage detection in 30 seconds")
|
||||
time.Sleep(30 * time.Second)
|
||||
err := storageDetect(endpoint, endpointService, factory)
|
||||
log.Err(err).Msg("final error while detecting storage classes")
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ func (kcl *KubeClient) CreateIngress(namespace string, info models.K8sIngressInf
|
||||
if ingress.Labels == nil {
|
||||
ingress.Labels = make(map[string]string)
|
||||
}
|
||||
ingress.Labels["io.portainer.kubernetes.application.owner"] = stackutils.SanitizeLabel(owner)
|
||||
ingress.Labels["io.portainer.kubernetes.ingress.owner"] = stackutils.SanitizeLabel(owner)
|
||||
|
||||
// Store TLS information.
|
||||
var tls []netv1.IngressTLS
|
||||
|
||||
@@ -2,6 +2,7 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/internal/randomstring"
|
||||
|
||||
@@ -133,10 +134,34 @@ func createRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, clust
|
||||
APIGroup: "rbac.authorization.k8s.io",
|
||||
},
|
||||
}
|
||||
_, err := roleBindingClient.Create(context.Background(), clusterRoleBinding, metav1.CreateOptions{})
|
||||
roleBinding, err := roleBindingClient.Create(context.Background(), clusterRoleBinding, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error creating role binding: " + clusterRoleBindingName)
|
||||
return err
|
||||
}
|
||||
|
||||
// Retry checkRoleBinding a maximum of 5 times with a 100ms wait after each attempt
|
||||
maxRetries := 5
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
err = checkRoleBinding(roleBindingClient, roleBinding.Name)
|
||||
time.Sleep(100 * time.Millisecond) // Wait for 100ms, even if the check passes
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func checkRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) error {
|
||||
_, err := roleBindingClient.Get(context.Background(), name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error finding rolebinding: " + name)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) {
|
||||
err := roleBindingClient.Delete(context.Background(), name, metav1.DeleteOptions{})
|
||||
if err != nil {
|
||||
|
||||
@@ -562,6 +562,12 @@ type (
|
||||
KubernetesData struct {
|
||||
Snapshots []KubernetesSnapshot `json:"Snapshots"`
|
||||
Configuration KubernetesConfiguration `json:"Configuration"`
|
||||
Flags KubernetesFlags `json:"Flags"`
|
||||
}
|
||||
|
||||
KubernetesFlags struct {
|
||||
IsServerMetricsDetected bool `json:"IsServerMetricsDetected"`
|
||||
IsServerStorageDetected bool `json:"IsServerStorageDetected"`
|
||||
}
|
||||
|
||||
// KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time
|
||||
|
||||
@@ -210,25 +210,12 @@ input[type='checkbox'] {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.blocklist-item--disabled {
|
||||
cursor: auto;
|
||||
background-color: var(--grey-12);
|
||||
}
|
||||
|
||||
.blocklist-item--selected {
|
||||
background-color: var(--bg-blocklist-item-selected-color);
|
||||
border: 2px solid var(--border-blocklist-item-selected-color);
|
||||
color: var(--text-blocklist-item-selected-color);
|
||||
}
|
||||
|
||||
.blocklist-item:not(.blocklist-item-not-interactive):hover {
|
||||
@apply border border-blue-7;
|
||||
cursor: pointer;
|
||||
|
||||
background-color: var(--bg-blocklist-hover-color);
|
||||
color: var(--text-blocklist-hover-color);
|
||||
}
|
||||
|
||||
.blocklist-item-box {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -121,11 +121,6 @@ pr-icon {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-only-icon {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
--blue-11: #3ea5ff;
|
||||
--blue-12: #41a6ff;
|
||||
--blue-14: #357ebd;
|
||||
--blue-15: #36bffa;
|
||||
|
||||
--red-1: #a94442;
|
||||
--red-2: #c7254e;
|
||||
@@ -222,7 +223,6 @@
|
||||
--border-table-color: var(--grey-19);
|
||||
--border-table-top-color: var(--grey-19);
|
||||
--border-datatable-top-color: var(--grey-10);
|
||||
--border-blocklist-color: var(--grey-44);
|
||||
--border-input-group-addon-color: var(--grey-44);
|
||||
--border-btn-default-color: var(--grey-44);
|
||||
--border-boxselector-color: var(--grey-6);
|
||||
@@ -231,7 +231,6 @@
|
||||
--border-navtabs-color: var(--ui-white);
|
||||
--border-codemirror-cursor-color: var(--black-color);
|
||||
--border-pre-color: var(--grey-43);
|
||||
--border-blocklist-item-selected-color: var(--grey-46);
|
||||
--border-pagination-span-color: var(--ui-white);
|
||||
--border-pagination-hover-color: var(--ui-white);
|
||||
--border-panel-color: var(--white-color);
|
||||
@@ -245,6 +244,7 @@
|
||||
--border-sortbutton: var(--grey-8);
|
||||
--border-bootbox: var(--ui-gray-5);
|
||||
--border-blocklist: var(--ui-gray-5);
|
||||
--border-blocklist-item-selected-color: var(--grey-46);
|
||||
--border-widget: var(--ui-gray-5);
|
||||
--border-nav-container-color: var(--ui-gray-5);
|
||||
--border-stepper-color: var(--ui-gray-4);
|
||||
@@ -408,7 +408,6 @@
|
||||
--border-table-color: var(--grey-3);
|
||||
--border-table-top-color: var(--grey-3);
|
||||
--border-datatable-top-color: var(--grey-3);
|
||||
--border-blocklist-color: var(--grey-3);
|
||||
--border-input-group-addon-color: var(--grey-38);
|
||||
--border-btn-default-color: var(--grey-38);
|
||||
--border-boxselector-color: var(--grey-1);
|
||||
@@ -417,6 +416,7 @@
|
||||
--border-navtabs-color: var(--grey-38);
|
||||
--border-codemirror-cursor-color: var(--white-color);
|
||||
--border-pre-color: var(--grey-3);
|
||||
--border-blocklist: var(--ui-gray-9);
|
||||
--border-blocklist-item-selected-color: var(--grey-38);
|
||||
--border-pagination-span-color: var(--grey-1);
|
||||
--border-pagination-hover-color: var(--grey-3);
|
||||
@@ -430,7 +430,6 @@
|
||||
--border-modal: 0px;
|
||||
--border-sortbutton: var(--grey-3);
|
||||
--border-bootbox: var(--ui-gray-9);
|
||||
--border-blocklist: var(--ui-gray-9);
|
||||
--border-widget: var(--grey-3);
|
||||
--border-pagination-color: var(--grey-1);
|
||||
--border-nav-container-color: var(--ui-gray-neutral-8);
|
||||
@@ -600,7 +599,6 @@
|
||||
--border-pre-color: var(--grey-3);
|
||||
--border-codemirror-cursor-color: var(--white-color);
|
||||
--border-modal: 1px solid var(--white-color);
|
||||
--border-blocklist-color: var(--white-color);
|
||||
--border-sortbutton: var(--black-color);
|
||||
--border-bootbox: var(--black-color);
|
||||
--border-blocklist: var(--white-color);
|
||||
|
||||
4
app/assets/ico/heartbeat-down.svg
Normal file
4
app/assets/ico/heartbeat-down.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="15" viewBox="0 0 16 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.91895 2.19267C6.33456 0.34043 3.69255 -0.157821 1.70745 1.53828C-0.277637 3.23438 -0.557105 6.0702 1.0018 8.07617C2.17927 9.59131 5.52429 12.6331 7.0907 14.0309C7.37867 14.2878 7.52266 14.4164 7.69121 14.4669C7.83757 14.5108 8.00026 14.5108 8.14662 14.4669C8.31517 14.4164 8.45916 14.2878 8.74713 14.0309C10.3135 12.6331 13.6586 9.59131 14.836 8.07617C16.3949 6.0702 16.1496 3.21655 14.1304 1.53828C12.1112 -0.139975 9.50327 0.34043 7.91895 2.19267Z" fill="#D92D20"/>
|
||||
<path d="M8.03754 9.71338L8.03754 4.94044M8.03754 9.71338L5.90125 7.57709M8.03754 9.71338L10.1738 7.57709" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 754 B |
4
app/assets/ico/heartbeat-up.svg
Normal file
4
app/assets/ico/heartbeat-up.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="15" viewBox="0 0 16 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.91895 2.19267C6.33456 0.34043 3.69255 -0.157821 1.70745 1.53828C-0.277637 3.23438 -0.557105 6.0702 1.0018 8.07617C2.17927 9.59131 5.52429 12.6331 7.0907 14.0309C7.37867 14.2878 7.52266 14.4164 7.69121 14.4669C7.83757 14.5108 8.00026 14.5108 8.14662 14.4669C8.31517 14.4164 8.45916 14.2878 8.74713 14.0309C10.3135 12.6331 13.6586 9.59131 14.836 8.07617C16.3949 6.0702 16.1496 3.21655 14.1304 1.53828C12.1112 -0.139975 9.50327 0.34043 7.91895 2.19267Z" fill="#039855"/>
|
||||
<path d="M8.03754 4.94043L8.03754 9.71337M8.03754 4.94043L10.1738 7.07672M8.03754 4.94043L5.90125 7.07672" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 754 B |
@@ -1,23 +0,0 @@
|
||||
import { compose, kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||
|
||||
export default class EdgeStackDeploymentTypeSelectorController {
|
||||
/* @ngInject */
|
||||
constructor() {
|
||||
this.deploymentOptions = [
|
||||
{
|
||||
...compose,
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
...kubernetes,
|
||||
value: 1,
|
||||
disabled: () => {
|
||||
return this.hasDockerEndpoint();
|
||||
},
|
||||
tooltip: () => {
|
||||
return this.hasDockerEndpoint() ? 'Cannot use this option with Edge Docker endpoints' : '';
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<div class="col-sm-12 form-section-title"> Deployment type </div>
|
||||
<box-selector radio-name="'deploymentType'" value="$ctrl.value" options="$ctrl.deploymentOptions" on-change="($ctrl.onChange)"></box-selector>
|
||||
@@ -1,15 +0,0 @@
|
||||
import angular from 'angular';
|
||||
import controller from './edge-stack-deployment-type-selector.controller.js';
|
||||
|
||||
export const edgeStackDeploymentTypeSelector = {
|
||||
templateUrl: './edge-stack-deployment-type-selector.html',
|
||||
controller,
|
||||
|
||||
bindings: {
|
||||
value: '<',
|
||||
onChange: '<',
|
||||
hasDockerEndpoint: '<',
|
||||
},
|
||||
};
|
||||
|
||||
angular.module('portainer.edge').component('edgeStackDeploymentTypeSelector', edgeStackDeploymentTypeSelector);
|
||||
@@ -4,30 +4,42 @@
|
||||
<div class="col-sm-12">
|
||||
<edge-groups-selector value="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="!$ctrl.validateEndpointsForDeployment()">
|
||||
<div class="col-sm-12">
|
||||
<div class="small text-muted space-right text-warning">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
One or more of the selected Edge group contains Edge Docker endpoints that cannot be used with a Kubernetes Edge stack.
|
||||
</div>
|
||||
</div>
|
||||
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.model.DeploymentType === undefined">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> There are no available deployment types when there is more than one type of environment in your edge group
|
||||
selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type.
|
||||
</p>
|
||||
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.model.DeploymentType === $ctrl.EditorType.Compose && $ctrl.hasKubeEndpoint()">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Edge groups with kubernetes environments no longer support compose deployment types in Portainer. Please select
|
||||
edge groups that only have docker environments when using compose deployment types.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<edge-stack-deployment-type-selector
|
||||
allow-kube-to-select-compose="$ctrl.allowKubeToSelectCompose"
|
||||
value="$ctrl.model.DeploymentType"
|
||||
has-docker-endpoint="$ctrl.hasDockerEndpoint"
|
||||
has-docker-endpoint="$ctrl.hasDockerEndpoint()"
|
||||
has-kube-endpoint="$ctrl.hasKubeEndpoint()"
|
||||
on-change="($ctrl.onChangeDeploymentType)"
|
||||
read-only="$ctrl.state.readOnlyCompose"
|
||||
></edge-stack-deployment-type-selector>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.model.DeploymentType === 0 && $ctrl.hasKubeEndpoint()">
|
||||
<div class="col-sm-12">
|
||||
<div class="small text-muted space-right">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
Portainer uses <a href="https://kompose.io/" target="_blank">Kompose</a> to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that not all the
|
||||
Compose format options are supported by Kompose at the moment.
|
||||
</div>
|
||||
<div class="flex gap-1 text-muted small" ng-show="!$ctrl.model.DeploymentType && $ctrl.hasKubeEndpoint()">
|
||||
<pr-icon icon="'alert-circle'" mode="'warning'" class-name="'!mt-1'"></pr-icon>
|
||||
<div>
|
||||
<p>
|
||||
Portainer no longer supports <a href="https://docs.docker.com/compose/compose-file/" target="_blank">docker-compose</a> format manifests for Kubernetes deployments, and we
|
||||
have removed the <a href="https://kompose.io/" target="_blank">Kompose</a> conversion tool which enables this. The reason for this is because Kompose now poses a security
|
||||
risk, since it has a number of Common Vulnerabilities and Exposures (CVEs).
|
||||
</p>
|
||||
<p
|
||||
>Unfortunately, while the Kompose project has a maintainer and is part of the CNCF, it is not being actively maintained. Releases are very infrequent and new pull requests
|
||||
to the project (including ones we've submitted) are taking months to be merged, with new CVEs arising in the meantime.</p
|
||||
>
|
||||
<p>
|
||||
We advise installing your own instance of Kompose in a sandbox environment, performing conversions of your Docker Compose files to Kubernetes manifests and using those
|
||||
manifests to set up applications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +50,7 @@
|
||||
identifier="compose-editor"
|
||||
placeholder="# Define or paste the content of your docker compose file here"
|
||||
on-change="($ctrl.onChangeComposeConfig)"
|
||||
read-only="$ctrl.hasKubeEndpoint()"
|
||||
>
|
||||
<editor-description>
|
||||
<div>
|
||||
@@ -82,8 +95,8 @@
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="$ctrl.actionInProgress || !$ctrl.isFormValid()"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="$ctrl.actionInProgress || !$ctrl.isFormValid() || (!$ctrl.model.DeploymentType && $ctrl.hasKubeEndpoint())"
|
||||
ng-click="$ctrl.submitAction()"
|
||||
button-spinner="$ctrl.actionInProgress"
|
||||
>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
|
||||
import { EditorType } from '@/react/edge/edge-stacks/types';
|
||||
|
||||
import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils';
|
||||
export class EditEdgeStackFormController {
|
||||
/* @ngInject */
|
||||
constructor($scope) {
|
||||
this.$scope = $scope;
|
||||
this.state = {
|
||||
endpointTypes: [],
|
||||
readOnlyCompose: false,
|
||||
};
|
||||
|
||||
this.fileContents = {
|
||||
@@ -26,6 +27,7 @@ export class EditEdgeStackFormController {
|
||||
this.removeLineBreaks = this.removeLineBreaks.bind(this);
|
||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||
this.onChangeUseManifestNamespaces = this.onChangeUseManifestNamespaces.bind(this);
|
||||
this.selectValidDeploymentType = this.selectValidDeploymentType.bind(this);
|
||||
}
|
||||
|
||||
onChangeUseManifestNamespaces(value) {
|
||||
@@ -45,8 +47,9 @@ export class EditEdgeStackFormController {
|
||||
onChangeGroups(groups) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.model.EdgeGroups = groups;
|
||||
|
||||
this.checkEndpointTypes(groups);
|
||||
this.setEnvironmentTypesInSelection(groups);
|
||||
this.selectValidDeploymentType();
|
||||
this.state.readOnlyCompose = this.hasKubeEndpoint();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,11 +57,19 @@ export class EditEdgeStackFormController {
|
||||
return this.model.EdgeGroups.length && this.model.StackFileContent && this.validateEndpointsForDeployment();
|
||||
}
|
||||
|
||||
checkEndpointTypes(groups) {
|
||||
setEnvironmentTypesInSelection(groups) {
|
||||
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
||||
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
||||
}
|
||||
|
||||
selectValidDeploymentType() {
|
||||
const validTypes = getValidEditorTypes(this.state.endpointTypes, this.allowKubeToSelectCompose);
|
||||
|
||||
if (!validTypes.includes(this.model.DeploymentType)) {
|
||||
this.onChangeDeploymentType(validTypes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
removeLineBreaks(value) {
|
||||
return value.replace(/(\r\n|\n|\r)/gm, '');
|
||||
}
|
||||
@@ -81,9 +92,10 @@ export class EditEdgeStackFormController {
|
||||
}
|
||||
|
||||
onChangeDeploymentType(deploymentType) {
|
||||
this.model.DeploymentType = deploymentType;
|
||||
|
||||
this.model.StackFileContent = this.fileContents[deploymentType];
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.model.DeploymentType = deploymentType;
|
||||
this.model.StackFileContent = this.fileContents[deploymentType];
|
||||
});
|
||||
}
|
||||
|
||||
validateEndpointsForDeployment() {
|
||||
@@ -91,6 +103,14 @@ export class EditEdgeStackFormController {
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.checkEndpointTypes(this.model.EdgeGroups);
|
||||
this.setEnvironmentTypesInSelection(this.model.EdgeGroups);
|
||||
this.fileContents[this.model.DeploymentType] = this.model.StackFileContent;
|
||||
|
||||
// allow kube to view compose if it's an existing kube compose stack
|
||||
const initiallyContainsKubeEnv = this.hasKubeEndpoint();
|
||||
const isComposeStack = this.model.DeploymentType === 0;
|
||||
this.allowKubeToSelectCompose = initiallyContainsKubeEnv && isComposeStack;
|
||||
this.state.readOnlyCompose = this.allowKubeToSelectCompose;
|
||||
this.selectValidDeploymentType();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
|
||||
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
|
||||
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
|
||||
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
|
||||
|
||||
export const componentsModule = angular
|
||||
.module('portainer.edge.react.components', [])
|
||||
@@ -43,4 +44,14 @@ export const componentsModule = angular
|
||||
'readonly',
|
||||
'fieldSettings',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'edgeStackDeploymentTypeSelector',
|
||||
r2a(withReactQuery(EdgeStackDeploymentTypeSelector), [
|
||||
'value',
|
||||
'onChange',
|
||||
'hasDockerEndpoint',
|
||||
'hasKubeEndpoint',
|
||||
'allowKubeToSelectCompose',
|
||||
])
|
||||
).name;
|
||||
|
||||
@@ -154,6 +154,7 @@ export class EdgeJobController {
|
||||
this.tags = tags;
|
||||
|
||||
this.edgeJob.EdgeGroups = this.edgeJob.EdgeGroups ? this.edgeJob.EdgeGroups : [];
|
||||
this.edgeJob.Endpoints = this.edgeJob.Endpoints ? this.edgeJob.Endpoints : [];
|
||||
|
||||
if (results.length > 0) {
|
||||
const endpointIds = _.map(results, (result) => result.EndpointId);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { EditorType } from '@/react/edge/edge-stacks/types';
|
||||
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
|
||||
import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils';
|
||||
|
||||
export default class CreateEdgeStackViewController {
|
||||
/* @ngInject */
|
||||
@@ -43,6 +45,7 @@ export default class CreateEdgeStackViewController {
|
||||
this.createStackFromGitRepository = this.createStackFromGitRepository.bind(this);
|
||||
this.onChangeGroups = this.onChangeGroups.bind(this);
|
||||
this.hasDockerEndpoint = this.hasDockerEndpoint.bind(this);
|
||||
this.hasKubeEndpoint = this.hasKubeEndpoint.bind(this);
|
||||
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
||||
}
|
||||
|
||||
@@ -134,18 +137,23 @@ export default class CreateEdgeStackViewController {
|
||||
checkIfEndpointTypes(groups) {
|
||||
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
||||
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
||||
this.selectValidDeploymentType();
|
||||
}
|
||||
|
||||
if (this.hasDockerEndpoint() && this.formValues.DeploymentType == 1) {
|
||||
this.onChangeDeploymentType(0);
|
||||
selectValidDeploymentType() {
|
||||
const validTypes = getValidEditorTypes(this.state.endpointTypes);
|
||||
|
||||
if (!validTypes.includes(this.formValues.DeploymentType)) {
|
||||
this.onChangeDeploymentType(validTypes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
hasKubeEndpoint() {
|
||||
return this.state.endpointTypes.includes(7);
|
||||
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment);
|
||||
}
|
||||
|
||||
hasDockerEndpoint() {
|
||||
return this.state.endpointTypes.includes(4);
|
||||
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnDockerEnvironment);
|
||||
}
|
||||
|
||||
validateForm(method) {
|
||||
@@ -217,9 +225,11 @@ export default class CreateEdgeStackViewController {
|
||||
}
|
||||
|
||||
onChangeDeploymentType(deploymentType) {
|
||||
this.formValues.DeploymentType = deploymentType;
|
||||
this.state.Method = 'editor';
|
||||
this.formValues.StackFileContent = '';
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.formValues.DeploymentType = deploymentType;
|
||||
this.state.Method = 'editor';
|
||||
this.formValues.StackFileContent = '';
|
||||
});
|
||||
}
|
||||
|
||||
formIsInvalid() {
|
||||
|
||||
@@ -39,24 +39,19 @@
|
||||
<div ng-if="$ctrl.noGroups" class="col-sm-12 small text-muted">
|
||||
No Edge groups are available. Head over to the <a ui-sref="edge.groups">Edge groups view</a> to create one.
|
||||
</div>
|
||||
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.formValues.DeploymentType === undefined">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> There are no available deployment types when there is more than one type of environment in your edge
|
||||
group selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<edge-stack-deployment-type-selector
|
||||
value="$ctrl.formValues.DeploymentType"
|
||||
has-docker-endpoint="$ctrl.hasDockerEndpoint"
|
||||
has-docker-endpoint="$ctrl.hasDockerEndpoint()"
|
||||
has-kube-endpoint="$ctrl.hasKubeEndpoint()"
|
||||
on-change="($ctrl.onChangeDeploymentType)"
|
||||
></edge-stack-deployment-type-selector>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<div class="small text-muted space-right" ng-if="$ctrl.formValues.DeploymentType === 0 && $ctrl.hasKubeEndpoint()">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
Portainer uses <a href="https://kompose.io/" target="_blank">Kompose</a> to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that not all
|
||||
the Compose format options are supported by Kompose at the moment.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<edge-stacks-docker-compose-form
|
||||
ng-if="$ctrl.formValues.DeploymentType == $ctrl.EditorType.Compose"
|
||||
form-values="$ctrl.formValues"
|
||||
|
||||
@@ -59,7 +59,11 @@ export class EditEdgeStackViewController {
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.formValues.StackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== this.oldFileContent.replace(/(\r\n|\n|\r)/gm, '') && this.state.isEditorDirty) {
|
||||
if (
|
||||
this.formValues.StackFileContent &&
|
||||
this.formValues.StackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== this.oldFileContent.replace(/(\r\n|\n|\r)/gm, '') &&
|
||||
this.state.isEditorDirty
|
||||
) {
|
||||
return this.ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
|
||||
42
app/global.d.ts
vendored
42
app/global.d.ts
vendored
@@ -30,11 +30,53 @@ declare module 'axios-progress-bar' {
|
||||
): void;
|
||||
}
|
||||
|
||||
interface HubSpotCreateFormOptions {
|
||||
/** User's portal ID */
|
||||
portalId: string;
|
||||
/** Unique ID of the form you wish to build */
|
||||
formId: string;
|
||||
|
||||
region: string;
|
||||
/**
|
||||
* jQuery style selector specifying an existing element on the page into which the form will be placed once built.
|
||||
*
|
||||
* NOTE: If you're including multiple forms on the page, it is strongly recommended that you include a separate, specific target for each form.
|
||||
*/
|
||||
target: string;
|
||||
/**
|
||||
* Callback that executes after form is validated, just before the data is actually sent.
|
||||
* This is for any logic that needs to execute during the submit.
|
||||
* Any changes will not be validated.
|
||||
* Takes the jQuery form object as the argument: onFormSubmit($form).
|
||||
*
|
||||
* Note: Performing a browser redirect in this callback is not recommended and could prevent the form submission
|
||||
*/
|
||||
onFormSubmit?: (form: JQuery<HTMLFormElement>) => void;
|
||||
/**
|
||||
* Callback when the data is actually sent.
|
||||
* This allows you to perform an action when the submission is fully complete,
|
||||
* such as displaying a confirmation or thank you message.
|
||||
*/
|
||||
onFormSubmitted?: (form: JQuery<HTMLFormElement>) => void;
|
||||
/**
|
||||
* Callback that executes after form is built, placed in the DOM, and validation has been initialized.
|
||||
* This is perfect for any logic that needs to execute when the form is on the page.
|
||||
*
|
||||
* Takes the jQuery form object as the argument: onFormReady($form)
|
||||
*/
|
||||
onFormReady?: (form: JQuery<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
/**
|
||||
* will be true if portainer is run as a Docker Desktop Extension
|
||||
*/
|
||||
ddExtension?: boolean;
|
||||
hbspt?: {
|
||||
forms: {
|
||||
create: (options: HubSpotCreateFormOptions) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
declare module 'process' {
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
<pr-icon class="vertical-center" icon="'check'" size="'md'" mode="'success'"></pr-icon> copied
|
||||
</span>
|
||||
|
||||
<be-only-button
|
||||
<be-teaser-button
|
||||
class="float-right"
|
||||
feature-id="$ctrl.limitedFeature"
|
||||
message="'Applies any changes that you make in the YAML editor by calling the Kubernetes API to patch the relevant resources. Any resource removals or unexpected resource additions that you make in the YAML will be ignored. Note that editing is disabled for resources in namespaces marked as system.'"
|
||||
heading="'Apply YAML changes'"
|
||||
button-text="'Apply changes'"
|
||||
></be-only-button>
|
||||
></be-teaser-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export const KubernetesDeployManifestTypes = Object.freeze({
|
||||
KUBERNETES: 1,
|
||||
COMPOSE: 2,
|
||||
});
|
||||
|
||||
export const KubernetesDeployBuildMethods = Object.freeze({
|
||||
|
||||
@@ -4,7 +4,6 @@ import { r2a } from '@/react-tools/react2angular';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { BEOnlyButton } from '@/kubernetes/react/views/beOnlyButton';
|
||||
import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable';
|
||||
import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView';
|
||||
|
||||
@@ -20,15 +19,4 @@ export const viewsModule = angular
|
||||
.component(
|
||||
'kubernetesIngressesCreateView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), [])
|
||||
)
|
||||
.component(
|
||||
'beOnlyButton',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(BEOnlyButton))), [
|
||||
'featureId',
|
||||
'heading',
|
||||
'message',
|
||||
'buttonText',
|
||||
'className',
|
||||
'icon',
|
||||
])
|
||||
).name;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<page-header
|
||||
ng-if="!ctrl.state.isEdit"
|
||||
ng-if="!ctrl.state.isEdit && !ctrl.stack.IsComposeFormat && ctrl.state.viewReady"
|
||||
title="'Create application'"
|
||||
breadcrumbs="[
|
||||
{ label:'Applications', link:'kubernetes.applications' },
|
||||
@@ -10,7 +10,7 @@
|
||||
</page-header>
|
||||
|
||||
<page-header
|
||||
ng-if="ctrl.state.isEdit"
|
||||
ng-if="ctrl.state.isEdit && !ctrl.stack.IsComposeFormat && ctrl.state.viewReady"
|
||||
title="'Edit application'"
|
||||
breadcrumbs="[
|
||||
{ label:'Namespaces', link:'kubernetes.resourcePools' },
|
||||
@@ -31,6 +31,28 @@
|
||||
>
|
||||
</page-header>
|
||||
|
||||
<page-header
|
||||
ng-if="ctrl.stack.IsComposeFormat"
|
||||
title="'View application'"
|
||||
breadcrumbs="[
|
||||
{ label:'Namespaces', link:'kubernetes.resourcePools' },
|
||||
{
|
||||
label:ctrl.application.ResourcePool,
|
||||
link: 'kubernetes.resourcePools.resourcePool',
|
||||
linkParams:{ id: ctrl.application.ResourcePool }
|
||||
},
|
||||
{ label:'Applications', link:'kubernetes.applications' },
|
||||
{
|
||||
label:ctrl.application.Name,
|
||||
link: 'kubernetes.applications.application',
|
||||
linkParams:{ name: ctrl.application.Name, namespace: ctrl.application.ResourcePool }
|
||||
},
|
||||
'View',
|
||||
]"
|
||||
reload="true"
|
||||
>
|
||||
</page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<div class="row kubernetes-create">
|
||||
@@ -88,6 +110,7 @@
|
||||
|
||||
<!-- #region web editor -->
|
||||
<web-editor-form
|
||||
read-only="ctrl.stack.IsComposeFormat"
|
||||
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT"
|
||||
value="ctrl.stackFileContent"
|
||||
yml="true"
|
||||
@@ -96,27 +119,24 @@
|
||||
on-change="(ctrl.onChangeFileContent)"
|
||||
>
|
||||
<editor-description>
|
||||
<span class="text-muted small" ng-show="ctrl.stack.IsComposeFormat">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
|
||||
<span>
|
||||
Portainer uses <a href="https://kompose.io/" target="_blank">Kompose</a> to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that
|
||||
not all the Compose format options are supported by Kompose at the moment.
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
You can get more information about Compose file format in the
|
||||
<a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
|
||||
</p>
|
||||
<p
|
||||
>In a forthcoming Portainer release, we plan to remove support for docker-compose format manifests for Kubernetes deployments, and the Kompose conversion tool
|
||||
which enables this. The reason for this is because Kompose now poses a security risk, since it has a number of Common Vulnerabilities and Exposures (CVEs).</p
|
||||
>
|
||||
<p
|
||||
>Unfortunately, while the Kompose project has a maintainer and is part of the CNCF, it is not being actively maintained. Releases are very infrequent and new
|
||||
pull requests to the project (including ones we've submitted) are taking months to be merged, with new CVEs arising in the meantime.</p
|
||||
>
|
||||
</span>
|
||||
<div class="flex gap-1 text-muted small" ng-show="ctrl.stack.IsComposeFormat">
|
||||
<pr-icon icon="'alert-circle'" mode="'warning'" class-name="'!mt-1'"></pr-icon>
|
||||
<div>
|
||||
<p>
|
||||
Portainer no longer supports <a href="https://docs.docker.com/compose/compose-file/" target="_blank">docker-compose</a> format manifests for Kubernetes
|
||||
deployments, and we have removed the <a href="https://kompose.io/" target="_blank">Kompose</a> conversion tool which enables this. The reason for this is
|
||||
because Kompose now poses a security risk, since it has a number of Common Vulnerabilities and Exposures (CVEs).
|
||||
</p>
|
||||
<p
|
||||
>Unfortunately, while the Kompose project has a maintainer and is part of the CNCF, it is not being actively maintained. Releases are very infrequent and
|
||||
new pull requests to the project (including ones we've submitted) are taking months to be merged, with new CVEs arising in the meantime.</p
|
||||
>
|
||||
<p>
|
||||
We advise installing your own instance of Kompose in a sandbox environment, performing conversions of your Docker Compose files to Kubernetes manifests and
|
||||
using those manifests to set up applications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-muted small" ng-show="!ctrl.stack.IsComposeFormat">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
@@ -1345,9 +1365,9 @@
|
||||
<!-- kubernetes summary for external application -->
|
||||
<kubernetes-summary-view ng-if="ctrl.isExternalApplication()" form-values="ctrl.formValues" old-form-values="ctrl.savedFormValues"></kubernetes-summary-view>
|
||||
<!-- kubernetes summary for external application -->
|
||||
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT"> Actions </div>
|
||||
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.GIT" ng-hide="ctrl.stack.IsComposeFormat"> Actions </div>
|
||||
<!-- #region ACTIONS -->
|
||||
<div class="form-group">
|
||||
<div class="form-group" ng-hide="ctrl.stack.IsComposeFormat">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
style="margin-left: 0"
|
||||
data-cy="k8sAppDetail-editAppButton"
|
||||
>
|
||||
<pr-icon icon="'code'" class="mr-1"></pr-icon>Edit this application
|
||||
<pr-icon icon="'pencil'" class="mr-1"></pr-icon>{{ ctrl.stack.IsComposeFormat ? 'View this application' : 'Edit this application' }}
|
||||
</button>
|
||||
<button
|
||||
authorization="K8sApplicationDetailsW"
|
||||
@@ -233,15 +233,17 @@
|
||||
ui-sref="kubernetes.applications.application.edit"
|
||||
data-cy="k8sAppDetail-editAppButton"
|
||||
>
|
||||
<pr-icon icon="'code'" class-name="'mr-1'"></pr-icon>Edit External application
|
||||
<pr-icon icon="'pencil'" class-name="'mr-1'"></pr-icon>Edit external application
|
||||
</button>
|
||||
<be-only-button
|
||||
<be-teaser-button
|
||||
icon="'refresh-cw'"
|
||||
feature-id="ctrl.limitedFeature"
|
||||
message="'A rolling restart of the application is performed.'"
|
||||
heading="'Rolling restart'"
|
||||
button-text="'Rolling restart'"
|
||||
></be-only-button>
|
||||
class-name="'be-tooltip-teaser'"
|
||||
className="'be-tooltip-teaser'"
|
||||
></be-teaser-button>
|
||||
<button
|
||||
ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD"
|
||||
type="button"
|
||||
|
||||
@@ -224,7 +224,7 @@ class KubernetesApplicationController {
|
||||
}
|
||||
|
||||
rollbackApplication() {
|
||||
this.ModalService.confirmUpdate('Rolling back the application to a previous configuration may cause a service interruption. Do you wish to continue?', (confirmed) => {
|
||||
this.ModalService.confirmUpdate('Rolling back the application to a previous configuration may cause service interruption. Do you wish to continue?', (confirmed) => {
|
||||
if (confirmed) {
|
||||
return this.$async(this.rollbackApplicationAsync);
|
||||
}
|
||||
@@ -234,6 +234,15 @@ class KubernetesApplicationController {
|
||||
* REDEPLOY
|
||||
*/
|
||||
async redeployApplicationAsync() {
|
||||
const confirmed = await this.ModalService.confirmAsync({
|
||||
title: 'Are you sure?',
|
||||
message: 'Redeploying the application may cause a service interruption. Do you wish to continue?',
|
||||
buttons: { confirm: { label: 'Redeploy', className: 'btn-primary' } },
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const promises = _.map(this.application.Pods, (item) => this.KubernetesPodService.delete(item));
|
||||
await Promise.all(promises);
|
||||
@@ -245,11 +254,7 @@ class KubernetesApplicationController {
|
||||
}
|
||||
|
||||
redeployApplication() {
|
||||
this.ModalService.confirmUpdate('Redeploying the application may cause a service interruption. Do you wish to continue?', (confirmed) => {
|
||||
if (confirmed) {
|
||||
return this.$async(this.redeployApplicationAsync);
|
||||
}
|
||||
});
|
||||
return this.$async(this.redeployApplicationAsync);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -318,6 +323,9 @@ class KubernetesApplicationController {
|
||||
this.KubernetesNodeService.get(),
|
||||
]);
|
||||
this.application = application;
|
||||
if (this.application.StackId) {
|
||||
this.stack = await this.StackService.stack(application.StackId);
|
||||
}
|
||||
this.allContainers = KubernetesApplicationHelper.associateAllContainersAndApplication(application);
|
||||
this.formValues.Note = this.application.Note;
|
||||
this.formValues.Services = this.application.Services;
|
||||
|
||||
@@ -116,20 +116,7 @@
|
||||
placeholder="# Define or paste the content of your manifest file here"
|
||||
>
|
||||
<editor-description>
|
||||
<span class="col-sm-12 text-muted small" ng-show="ctrl.state.DeployType === ctrl.ManifestDeployTypes.COMPOSE">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
|
||||
<span>
|
||||
Portainer uses <a href="https://kompose.io/" target="_blank">Kompose</a> to convert your Compose manifest to a Kubernetes compliant manifest. Be wary
|
||||
that not all the Compose format options are supported by Kompose at the moment.
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
You can get more information about Compose file format in the
|
||||
<a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
|
||||
</p>
|
||||
</span>
|
||||
<span class="col-sm-12 text-muted small" ng-show="ctrl.state.DeployType === ctrl.ManifestDeployTypes.KUBERNETES">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...).
|
||||
|
||||
@@ -7,9 +7,8 @@ import PortainerError from '@/portainer/error';
|
||||
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
|
||||
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { compose, kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
|
||||
import { editor, git, template, url } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { getPublicSettings } from '@/react/portainer/settings/settings.service';
|
||||
|
||||
class KubernetesDeployController {
|
||||
/* @ngInject */
|
||||
@@ -339,16 +338,6 @@ class KubernetesDeployController {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const publicSettings = await getPublicSettings();
|
||||
this.showKomposeBuildOption = publicSettings.ShowKomposeBuildOption;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to get public settings');
|
||||
}
|
||||
if (this.showKomposeBuildOption) {
|
||||
this.deployOptions = [...this.deployOptions, { ...compose, value: KubernetesDeployManifestTypes.COMPOSE }];
|
||||
}
|
||||
|
||||
this.state.viewReady = true;
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
|
||||
@@ -50,14 +50,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable && !ctrl.isQuotaValid()">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
Not enough resources available in the cluster to apply a resource reservation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.HasQuota">
|
||||
<kubernetes-resource-reservation
|
||||
ng-if="ctrl.pool.Quota"
|
||||
@@ -76,6 +68,14 @@
|
||||
<div ng-if="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable">
|
||||
<div class="col-sm-12 form-section-title"> Resource limits </div>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 small text-warning" ng-switch on="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable && !ctrl.isQuotaValid()">
|
||||
<p class="vertical-center mb-0" ng-switch-when="true"
|
||||
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> At least a single limit must be set for the quota to be valid.
|
||||
</p>
|
||||
<p class="vertical-center mb-0" ng-switch-default></p>
|
||||
</span>
|
||||
</div>
|
||||
<!-- memory-limit-input -->
|
||||
<div class="form-group flex">
|
||||
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left vertical-center"> Memory limit (MB) </label>
|
||||
|
||||
@@ -5,6 +5,10 @@ angular.module('portainer.app').controller('CodeEditorController', function Code
|
||||
if (value && value.currentValue && ctrl.editor && ctrl.editor.getValue() !== value.currentValue) {
|
||||
ctrl.editor.setValue(value.currentValue);
|
||||
}
|
||||
|
||||
if (ctrl.editor) {
|
||||
ctrl.editor.setOption('readOnly', ctrl.readOnly);
|
||||
}
|
||||
};
|
||||
|
||||
this.$onInit = function () {
|
||||
|
||||
@@ -111,17 +111,13 @@
|
||||
|
||||
.datatable .footer .paginationControls {
|
||||
float: right;
|
||||
margin: 10px 0 5px 0;
|
||||
margin: 10px 10px 5px 0;
|
||||
}
|
||||
|
||||
.datatable .footer .paginationControls .limitSelector {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.datatable .footer .paginationControls .limitSelector:not(:last-child) {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.datatable .footer .paginationControls .pagination {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<ng-form name="autoUpdateForm form-group">
|
||||
<ng-form name="autoUpdateForm" class="form-group">
|
||||
<div class="small vertical-center mb-2">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span class="text-muted">
|
||||
@@ -44,8 +44,8 @@
|
||||
<label for="repository_mechanism" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Webhook
|
||||
<portainer-tooltip
|
||||
message="$ctrl.environmentType === 'KUBERNETES' ?
|
||||
'See <a href=\'https://docs.portainer.io/user/kubernetes/applications/manifest#automatic-updates\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.' :
|
||||
message="$ctrl.environmentType === 'KUBERNETES' ?
|
||||
'See <a href=\'https://docs.portainer.io/user/kubernetes/applications/webhooks\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.' :
|
||||
'See <a href=\'https://docs.portainer.io/user/docker/stacks/webhooks\' target=\'_blank\' rel=\'noreferrer\'>Portainer documentation on webhook usage</a>.'"
|
||||
set-html-message="true"
|
||||
></portainer-tooltip>
|
||||
|
||||
@@ -20,7 +20,6 @@ export function SettingsViewModel(data) {
|
||||
this.EnforceEdgeID = data.EnforceEdgeID;
|
||||
this.AgentSecret = data.AgentSecret;
|
||||
this.EdgePortainerUrl = data.EdgePortainerUrl;
|
||||
this.ShowKomposeBuildOption = data.ShowKomposeBuildOption;
|
||||
}
|
||||
|
||||
export function PublicSettingsViewModel(settings) {
|
||||
@@ -37,7 +36,6 @@ export function PublicSettingsViewModel(settings) {
|
||||
this.Features = settings.Features;
|
||||
this.Edge = new EdgeSettingsViewModel(settings.Edge);
|
||||
this.DefaultRegistry = settings.DefaultRegistry;
|
||||
this.ShowKomposeBuildOption = settings.ShowKomposeBuildOption;
|
||||
this.IsAMTEnabled = settings.IsAMTEnabled;
|
||||
this.IsFDOEnabled = settings.IsFDOEnabled;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { TeamsSelector } from '@@/TeamsSelector';
|
||||
import { PortainerSelect } from '@@/form-components/PortainerSelect';
|
||||
import { Slider } from '@@/form-components/Slider';
|
||||
import { TagButton } from '@@/TagButton';
|
||||
import { BETeaserButton } from '@@/BETeaserButton';
|
||||
|
||||
import { fileUploadField } from './file-upload-field';
|
||||
import { switchField } from './switch-field';
|
||||
@@ -44,7 +45,22 @@ export const componentsModule = angular
|
||||
.module('portainer.app.react.components', [customTemplatesModule])
|
||||
.component(
|
||||
'tagSelector',
|
||||
r2a(withReactQuery(TagSelector), ['allowCreate', 'onChange', 'value'])
|
||||
r2a(withUIRouter(withReactQuery(TagSelector)), [
|
||||
'allowCreate',
|
||||
'onChange',
|
||||
'value',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'beTeaserButton',
|
||||
r2a(BETeaserButton, [
|
||||
'featureId',
|
||||
'heading',
|
||||
'message',
|
||||
'buttonText',
|
||||
'className',
|
||||
'icon',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'tagButton',
|
||||
|
||||
@@ -32,8 +32,8 @@ export const viewsModule = angular
|
||||
)
|
||||
.component(
|
||||
'settingsEdgeCompute',
|
||||
r2a(withReactQuery(withCurrentUser(EdgeComputeSettingsView)), [
|
||||
'onSubmit',
|
||||
'settings',
|
||||
])
|
||||
r2a(
|
||||
withUIRouter(withReactQuery(withCurrentUser(EdgeComputeSettingsView))),
|
||||
['onSubmit', 'settings']
|
||||
)
|
||||
).name;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import { UserTokenModel, UserViewModel } from '@/portainer/models/user';
|
||||
import { getUser, getUsers } from '@/portainer/users/user.service';
|
||||
import { getUsers } from '@/portainer/users/user.service';
|
||||
import { getUser } from '@/portainer/users/queries/useUser';
|
||||
|
||||
import { TeamMembershipModel } from '../../models/teamMembership';
|
||||
|
||||
@@ -15,8 +17,8 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
|
||||
return users.map((u) => new UserViewModel(u));
|
||||
};
|
||||
|
||||
service.user = async function (includeAdministrators) {
|
||||
const user = await getUser(includeAdministrators);
|
||||
service.user = async function (userId) {
|
||||
const user = await getUser(userId);
|
||||
|
||||
return new UserViewModel(user);
|
||||
};
|
||||
|
||||
27
app/portainer/users/queries/useUser.ts
Normal file
27
app/portainer/users/queries/useUser.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { buildUrl } from '../user.service';
|
||||
import { User, UserId } from '../types';
|
||||
|
||||
export function useUser(
|
||||
id: UserId,
|
||||
{ staleTime }: { staleTime?: number } = {}
|
||||
) {
|
||||
return useQuery(['users', id], () => getUser(id), {
|
||||
...withError('Unable to retrieve user details'),
|
||||
staleTime,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUser(id: UserId) {
|
||||
try {
|
||||
const { data: user } = await axios.get<User>(buildUrl(id));
|
||||
|
||||
return user;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve user details');
|
||||
}
|
||||
}
|
||||
@@ -19,16 +19,6 @@ export async function getUsers(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUser(id: UserId) {
|
||||
try {
|
||||
const { data: user } = await axios.get<User>(buildUrl(id));
|
||||
|
||||
return user;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve user details');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserMemberships(id: UserId) {
|
||||
try {
|
||||
const { data } = await axios.get<TeamMembership[]>(
|
||||
@@ -40,7 +30,7 @@ export async function getUserMemberships(id: UserId) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(id?: UserId, entity?: string) {
|
||||
export function buildUrl(id?: UserId, entity?: string) {
|
||||
let url = '/users';
|
||||
|
||||
if (id) {
|
||||
|
||||
@@ -184,16 +184,6 @@
|
||||
tooltip="'Hides the \'Add with form\' buttons and prevents adding/editing of resources via forms'"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<por-switch-field
|
||||
label="'Allow docker-compose format Kubernetes manifests'"
|
||||
checked="formValues.ShowKomposeBuildOption"
|
||||
name="'toggle_showKomposeBuildOption'"
|
||||
on-change="(onToggleShowKompose)"
|
||||
field-class="'col-sm-12'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
<!-- !deployment options -->
|
||||
<!-- actions -->
|
||||
<div class="form-group">
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
// import trackEvent directly because the event only fires once with $analytics.trackEvent
|
||||
import { trackEvent } from '@/angulartics.matomo/analytics-services';
|
||||
import { options } from './options';
|
||||
|
||||
angular.module('portainer.app').controller('SettingsController', [
|
||||
'$scope',
|
||||
'$analytics',
|
||||
'$state',
|
||||
'Notifications',
|
||||
'SettingsService',
|
||||
'ModalService',
|
||||
@@ -16,7 +12,7 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||
'BackupService',
|
||||
'FileSaver',
|
||||
'Blob',
|
||||
function ($scope, $analytics, $state, Notifications, SettingsService, ModalService, StateManager, BackupService, FileSaver) {
|
||||
function ($scope, Notifications, SettingsService, ModalService, StateManager, BackupService, FileSaver) {
|
||||
$scope.customBannerFeatureId = FeatureId.CUSTOM_LOGIN_BANNER;
|
||||
$scope.s3BackupFeatureId = FeatureId.S3_BACKUP_SETTING;
|
||||
$scope.enforceDeploymentOptions = FeatureId.ENFORCE_DEPLOYMENT_OPTIONS;
|
||||
@@ -57,7 +53,6 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||
|
||||
$scope.formValues = {
|
||||
customLogo: false,
|
||||
ShowKomposeBuildOption: false,
|
||||
KubeconfigExpiry: undefined,
|
||||
HelmRepositoryURL: undefined,
|
||||
BlackListedLabels: [],
|
||||
@@ -83,33 +78,6 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||
});
|
||||
};
|
||||
|
||||
$scope.onToggleShowKompose = async function onToggleShowKompose(checked) {
|
||||
if (checked) {
|
||||
ModalService.confirmWarn({
|
||||
title: 'Are you sure?',
|
||||
message: `<p>In a forthcoming Portainer release, we plan to remove support for docker-compose format manifests for Kubernetes deployments, and the Kompose conversion tool which enables this. The reason for this is because Kompose now poses a security risk, since it has a number of Common Vulnerabilities and Exposures (CVEs).</p>
|
||||
<p>Unfortunately, while the Kompose project has a maintainer and is part of the CNCF, it is not being actively maintained. Releases are very infrequent and new pull requests to the project (including ones we've submitted) are taking months to be merged, with new CVEs arising in the meantime.</p>`,
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Ok',
|
||||
className: 'btn-warning',
|
||||
},
|
||||
},
|
||||
callback: function (confirmed) {
|
||||
$scope.setShowCompose(confirmed);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
$scope.setShowCompose(checked);
|
||||
};
|
||||
|
||||
$scope.setShowCompose = function setShowCompose(checked) {
|
||||
return $scope.$evalAsync(() => {
|
||||
$scope.formValues.ShowKomposeBuildOption = checked;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.onToggleAutoBackups = function onToggleAutoBackups(checked) {
|
||||
$scope.$evalAsync(() => {
|
||||
$scope.formValues.scheduleAutomaticBackups = checked;
|
||||
@@ -187,13 +155,8 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||
KubeconfigExpiry: $scope.formValues.KubeconfigExpiry,
|
||||
HelmRepositoryURL: $scope.formValues.HelmRepositoryURL,
|
||||
GlobalDeploymentOptions: $scope.formValues.GlobalDeploymentOptions,
|
||||
ShowKomposeBuildOption: $scope.formValues.ShowKomposeBuildOption,
|
||||
};
|
||||
|
||||
if (kubeSettingsPayload.ShowKomposeBuildOption !== $scope.initialFormValues.ShowKomposeBuildOption && $scope.initialFormValues.enableTelemetry) {
|
||||
trackEvent('kubernetes-allow-compose', { category: 'kubernetes', metadata: { 'kubernetes-allow-compose': kubeSettingsPayload.ShowKomposeBuildOption } });
|
||||
}
|
||||
|
||||
$scope.state.kubeSettingsActionInProgress = true;
|
||||
updateSettings(kubeSettingsPayload, 'Kubernetes settings updated');
|
||||
};
|
||||
@@ -205,7 +168,6 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||
StateManager.updateLogo(settings.LogoURL);
|
||||
StateManager.updateSnapshotInterval(settings.SnapshotInterval);
|
||||
StateManager.updateEnableTelemetry(settings.EnableTelemetry);
|
||||
$scope.initialFormValues.ShowKomposeBuildOption = response.ShowKomposeBuildOption;
|
||||
$scope.initialFormValues.enableTelemetry = response.EnableTelemetry;
|
||||
$scope.formValues.BlackListedLabels = response.BlackListedLabels;
|
||||
})
|
||||
@@ -235,11 +197,6 @@ angular.module('portainer.app').controller('SettingsController', [
|
||||
$scope.formValues.KubeconfigExpiry = settings.KubeconfigExpiry;
|
||||
$scope.formValues.HelmRepositoryURL = settings.HelmRepositoryURL;
|
||||
$scope.formValues.BlackListedLabels = settings.BlackListedLabels;
|
||||
if (settings.ShowKomposeBuildOption) {
|
||||
$scope.formValues.ShowKomposeBuildOption = settings.ShowKomposeBuildOption;
|
||||
}
|
||||
|
||||
$scope.initialFormValues.ShowKomposeBuildOption = settings.ShowKomposeBuildOption;
|
||||
$scope.initialFormValues.enableTelemetry = settings.EnableTelemetry;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ComponentType } from 'react';
|
||||
|
||||
import { UserProvider } from '@/react/hooks/useUser';
|
||||
|
||||
import { withReactQuery } from './withReactQuery';
|
||||
|
||||
export function withCurrentUser<T>(
|
||||
WrappedComponent: ComponentType<T>
|
||||
): ComponentType<T> {
|
||||
@@ -12,13 +14,14 @@ export function withCurrentUser<T>(
|
||||
function WrapperComponent(props: T) {
|
||||
return (
|
||||
<UserProvider>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<WrappedComponent {...props} />
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
WrapperComponent.displayName = displayName;
|
||||
WrapperComponent.displayName = `withCurrentUser(${displayName})`;
|
||||
|
||||
return WrapperComponent;
|
||||
// User provider makes a call to the API to get the current user.
|
||||
// We need to wrap it with React Query to make that call.
|
||||
return withReactQuery(WrapperComponent);
|
||||
}
|
||||
|
||||
@@ -10,13 +10,12 @@ export function withI18nSuspense<T>(
|
||||
function WrapperComponent(props: T) {
|
||||
return (
|
||||
<Suspense fallback="Loading translations...">
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<WrappedComponent {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
WrapperComponent.displayName = displayName;
|
||||
WrapperComponent.displayName = `withI18nSuspense(${displayName})`;
|
||||
|
||||
return WrapperComponent;
|
||||
}
|
||||
|
||||
@@ -14,13 +14,12 @@ export function withReactQuery<T>(
|
||||
function WrapperComponent(props: T) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<WrappedComponent {...props} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
WrapperComponent.displayName = displayName;
|
||||
WrapperComponent.displayName = `withReactQuery(${displayName})`;
|
||||
|
||||
return WrapperComponent;
|
||||
}
|
||||
|
||||
@@ -11,13 +11,12 @@ export function withUIRouter<T>(
|
||||
function WrapperComponent(props: T) {
|
||||
return (
|
||||
<UIRouterContextComponent>
|
||||
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
|
||||
<WrappedComponent {...props} />
|
||||
</UIRouterContextComponent>
|
||||
);
|
||||
}
|
||||
|
||||
WrapperComponent.displayName = displayName;
|
||||
WrapperComponent.displayName = `withUIRouter(${displayName})`;
|
||||
|
||||
return WrapperComponent;
|
||||
}
|
||||
|
||||
43
app/react/components/Alert/Alert.stories.tsx
Normal file
43
app/react/components/Alert/Alert.stories.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Meta, Story } from '@storybook/react';
|
||||
|
||||
import { Alert } from './Alert';
|
||||
|
||||
export default {
|
||||
component: Alert,
|
||||
title: 'Components/Alert',
|
||||
} as Meta;
|
||||
|
||||
interface Args {
|
||||
color: 'success' | 'error' | 'info';
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function Template({ text, color, title }: Args) {
|
||||
return (
|
||||
<Alert color={color} title={title}>
|
||||
{text}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
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: Story<Args> = Template.bind({});
|
||||
Error.args = {
|
||||
color: 'error',
|
||||
title: 'Error',
|
||||
text: 'This is an error alert',
|
||||
};
|
||||
|
||||
export const Info: Story<Args> = Template.bind({});
|
||||
Info.args = {
|
||||
color: 'info',
|
||||
title: 'Info',
|
||||
text: 'This is an info alert',
|
||||
};
|
||||
83
app/react/components/Alert/Alert.tsx
Normal file
83
app/react/components/Alert/Alert.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import clsx from 'clsx';
|
||||
import { AlertCircle, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
type AlertType = 'success' | 'error' | 'info';
|
||||
|
||||
const alertSettings: Record<
|
||||
AlertType,
|
||||
{ container: string; header: string; body: string; icon: ReactNode }
|
||||
> = {
|
||||
success: {
|
||||
container:
|
||||
'border-green-4 bg-green-2 th-dark:bg-green-3 th-dark:border-green-5',
|
||||
header: 'text-green-8',
|
||||
body: 'text-green-7',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
error: {
|
||||
container:
|
||||
'border-error-4 bg-error-2 th-dark:bg-error-3 th-dark:border-error-5',
|
||||
header: 'text-error-8',
|
||||
body: 'text-error-7',
|
||||
icon: XCircle,
|
||||
},
|
||||
info: {
|
||||
container:
|
||||
'border-blue-4 bg-blue-2 th-dark:bg-blue-3 th-dark:border-blue-5',
|
||||
header: 'text-blue-8',
|
||||
body: 'text-blue-7',
|
||||
icon: AlertCircle,
|
||||
},
|
||||
};
|
||||
|
||||
export function Alert({
|
||||
color,
|
||||
title,
|
||||
children,
|
||||
}: PropsWithChildren<{ color: AlertType; title: string }>) {
|
||||
const { container, header, body, icon } = alertSettings[color];
|
||||
|
||||
return (
|
||||
<AlertContainer className={container}>
|
||||
<AlertHeader className={header}>
|
||||
<Icon icon={icon} />
|
||||
{title}
|
||||
</AlertHeader>
|
||||
<AlertBody className={body}>{children}</AlertBody>
|
||||
</AlertContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertContainer({
|
||||
className,
|
||||
children,
|
||||
}: PropsWithChildren<{ className?: string }>) {
|
||||
return (
|
||||
<div className={clsx('border-2 border-solid rounded-md', 'p-3', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertHeader({
|
||||
className,
|
||||
children,
|
||||
}: PropsWithChildren<{ className?: string }>) {
|
||||
return (
|
||||
<h4
|
||||
className={clsx('text-base', 'flex gap-2 items-center !m-0', className)}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertBody({
|
||||
className,
|
||||
children,
|
||||
}: PropsWithChildren<{ className?: string }>) {
|
||||
return <div className={clsx('ml-6 mt-2 text-sm', className)}>{children}</div>;
|
||||
}
|
||||
1
app/react/components/Alert/index.ts
Normal file
1
app/react/components/Alert/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Alert } from './Alert';
|
||||
@@ -14,7 +14,7 @@ interface Props {
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function BEOnlyButton({
|
||||
export function BETeaserButton({
|
||||
featureId,
|
||||
heading,
|
||||
message,
|
||||
@@ -29,16 +29,18 @@ export function BEOnlyButton({
|
||||
BEFeatureID={featureId}
|
||||
message={message}
|
||||
>
|
||||
<Button
|
||||
icon={icon}
|
||||
type="button"
|
||||
color="warninglight"
|
||||
size="small"
|
||||
onClick={() => {}}
|
||||
disabled
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
<span>
|
||||
<Button
|
||||
icon={icon}
|
||||
type="button"
|
||||
color="warninglight"
|
||||
size="small"
|
||||
onClick={() => {}}
|
||||
disabled
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipWithChildren>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
|
||||
import './BoxSelectorItem.css';
|
||||
|
||||
@@ -29,7 +29,7 @@ export function BoxOption<T extends number | string>({
|
||||
type = 'radio',
|
||||
children,
|
||||
}: PropsWithChildren<Props<T>>) {
|
||||
return (
|
||||
const BoxOption = (
|
||||
<div className={clsx('box-selector-item', className)}>
|
||||
<input
|
||||
type={type}
|
||||
@@ -44,13 +44,13 @@ export function BoxOption<T extends number | string>({
|
||||
<label htmlFor={option.id} data-cy={`${radioName}_${option.value}`}>
|
||||
{children}
|
||||
</label>
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
position="bottom"
|
||||
className="portainer-tooltip"
|
||||
message={tooltip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipWithChildren message={tooltip}>{BoxOption}</TooltipWithChildren>
|
||||
);
|
||||
}
|
||||
return BoxOption;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createMockEnvironment } from '@/react-tools/test-mocks';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
import { rest, server } from '@/setup-tests/server';
|
||||
|
||||
import { EdgeIndicator } from './EdgeIndicator';
|
||||
|
||||
@@ -25,8 +24,6 @@ async function renderComponent(
|
||||
checkInInterval = 0,
|
||||
queryDate = 0
|
||||
) {
|
||||
server.use(rest.get('/api/settings', (req, res, ctx) => res(ctx.json({}))));
|
||||
|
||||
const environment = createMockEnvironment();
|
||||
|
||||
environment.EdgeID = edgeId;
|
||||
|
||||
@@ -27,7 +27,7 @@ export function EdgeIndicator({
|
||||
return (
|
||||
<span role="status" aria-label="edge-status">
|
||||
<EnvironmentStatusBadgeItem aria-label="unassociated">
|
||||
<s>associated</s>
|
||||
<span className="whitespace-nowrap">Not associated</span>
|
||||
</EnvironmentStatusBadgeItem>
|
||||
</span>
|
||||
);
|
||||
@@ -41,6 +41,7 @@ export function EdgeIndicator({
|
||||
>
|
||||
<EnvironmentStatusBadgeItem
|
||||
color={isValid ? 'success' : 'danger'}
|
||||
icon={isValid ? 'svg-heartbeatup' : 'svg-heartbeatdown'}
|
||||
aria-label="edge-heartbeat"
|
||||
>
|
||||
heartbeat
|
||||
|
||||
112
app/react/components/HubspotForm.tsx
Normal file
112
app/react/components/HubspotForm.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ReactNode, useRef } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
let globalId = 0;
|
||||
|
||||
interface Props {
|
||||
portalId: HubSpotCreateFormOptions['portalId'];
|
||||
formId: HubSpotCreateFormOptions['formId'];
|
||||
region: HubSpotCreateFormOptions['region'];
|
||||
|
||||
onSubmitted: () => void;
|
||||
|
||||
loading?: ReactNode;
|
||||
}
|
||||
|
||||
export function HubspotForm({
|
||||
loading,
|
||||
portalId,
|
||||
region,
|
||||
formId,
|
||||
onSubmitted,
|
||||
}: Props) {
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
const id = useRef(`reactHubspotForm${globalId++}`);
|
||||
const { isLoading } = useHubspotForm({
|
||||
elId: id.current,
|
||||
formId,
|
||||
portalId,
|
||||
region,
|
||||
onSubmitted,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={elRef}
|
||||
id={id.current}
|
||||
style={{ display: isLoading ? 'none' : 'block' }}
|
||||
/>
|
||||
{isLoading && loading}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useHubspotForm({
|
||||
elId,
|
||||
formId,
|
||||
portalId,
|
||||
region,
|
||||
onSubmitted,
|
||||
}: {
|
||||
elId: string;
|
||||
portalId: HubSpotCreateFormOptions['portalId'];
|
||||
formId: HubSpotCreateFormOptions['formId'];
|
||||
region: HubSpotCreateFormOptions['region'];
|
||||
|
||||
onSubmitted: () => void;
|
||||
}) {
|
||||
return useQuery(
|
||||
['hubspot', { elId, formId, portalId, region }],
|
||||
async () => {
|
||||
await loadHubspot();
|
||||
await createForm(`#${elId}`, {
|
||||
formId,
|
||||
portalId,
|
||||
region,
|
||||
onFormSubmitted: onSubmitted,
|
||||
});
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function loadHubspot() {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (window.hbspt) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement(`script`);
|
||||
|
||||
script.defer = true;
|
||||
script.onload = () => {
|
||||
resolve();
|
||||
};
|
||||
script.src = `//js.hsforms.net/forms/v2.js`;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
async function createForm(
|
||||
target: string,
|
||||
options: Omit<HubSpotCreateFormOptions, 'target'>
|
||||
) {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (!window.hbspt) {
|
||||
throw new Error('hbspt object is missing');
|
||||
}
|
||||
|
||||
window.hbspt.forms.create({
|
||||
...options,
|
||||
target,
|
||||
onFormReady(...rest) {
|
||||
options.onFormReady?.(...rest);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -25,14 +25,10 @@ export function InformationPanel({
|
||||
<WidgetBody className={bodyClassName}>
|
||||
<div style={wrapperStyle}>
|
||||
{title && (
|
||||
<div className="col-sm-12 form-section-title">
|
||||
<span style={{ float: 'left' }}>{title}</span>
|
||||
<div className="form-section-title">
|
||||
<span>{title}</span>
|
||||
{!!onDismiss && (
|
||||
<span
|
||||
className="small"
|
||||
style={{ float: 'right' }}
|
||||
ng-if="dismissAction"
|
||||
>
|
||||
<span className="small" style={{ float: 'right' }}>
|
||||
<Button color="link" icon={X} onClick={() => onDismiss()}>
|
||||
dismiss
|
||||
</Button>
|
||||
|
||||
@@ -16,7 +16,6 @@ export function LinkButton({
|
||||
return (
|
||||
<Button
|
||||
title={title}
|
||||
size="medium"
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
className={clsx(className, '!m-0 no-link')}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { ItemsPerPageSelector } from './ItemsPerPageSelector';
|
||||
import { PageSelector } from './PageSelector';
|
||||
|
||||
@@ -9,6 +11,7 @@ interface Props {
|
||||
showAll?: boolean;
|
||||
totalCount: number;
|
||||
isPageInputVisible?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PaginationControls({
|
||||
@@ -19,9 +22,10 @@ export function PaginationControls({
|
||||
onPageChange,
|
||||
totalCount,
|
||||
isPageInputVisible,
|
||||
className,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="paginationControls">
|
||||
<div className={clsx('paginationControls', className)}>
|
||||
<div className="form-inline flex">
|
||||
<ItemsPerPageSelector
|
||||
value={pageLimit}
|
||||
|
||||
@@ -4,6 +4,8 @@ import darkmode from '@/assets/ico/theme/darkmode.svg?c';
|
||||
import lightmode from '@/assets/ico/theme/lightmode.svg?c';
|
||||
import highcontrastmode from '@/assets/ico/theme/highcontrastmode.svg?c';
|
||||
// general icons
|
||||
import heartbeatup from '@/assets/ico/heartbeat-up.svg?c';
|
||||
import heartbeatdown from '@/assets/ico/heartbeat-down.svg?c';
|
||||
import checked from '@/assets/ico/checked.svg?c';
|
||||
import dataflow from '@/assets/ico/dataflow-1.svg?c';
|
||||
import git from '@/assets/ico/git.svg?c';
|
||||
@@ -44,6 +46,8 @@ import quay from '@/assets/ico/vendor/quay.svg?c';
|
||||
const placeholder = Placeholder;
|
||||
|
||||
export const SvgIcons = {
|
||||
heartbeatup,
|
||||
heartbeatdown,
|
||||
automode,
|
||||
darkmode,
|
||||
lightmode,
|
||||
|
||||
@@ -1,41 +1,35 @@
|
||||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Icon, IconMode } from '@@/Icon';
|
||||
|
||||
type Color = 'orange' | 'blue';
|
||||
|
||||
export interface Props {
|
||||
icon?: React.ReactNode;
|
||||
color?: Color;
|
||||
}
|
||||
|
||||
export function TextTip({
|
||||
color = 'orange',
|
||||
icon = AlertCircle,
|
||||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
let iconClass: string;
|
||||
|
||||
switch (color) {
|
||||
case 'blue':
|
||||
iconClass = 'icon-primary';
|
||||
break;
|
||||
case 'orange':
|
||||
iconClass = 'icon-warning';
|
||||
break;
|
||||
default:
|
||||
iconClass = 'icon-warning';
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="small vertical-center">
|
||||
<i className="icon-container">
|
||||
<Icon
|
||||
icon={AlertCircle}
|
||||
className={clsx(`${iconClass}`, 'space-right')}
|
||||
/>
|
||||
</i>
|
||||
<p className="small flex items-center gap-1">
|
||||
<Icon icon={icon} mode={getMode(color)} />
|
||||
|
||||
<span className="text-muted">{children}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function getMode(color: Color): IconMode {
|
||||
switch (color) {
|
||||
case 'blue':
|
||||
return 'primary';
|
||||
case 'orange':
|
||||
default:
|
||||
return 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.tooltip-message a {
|
||||
color: var(--blue-15) !important;
|
||||
}
|
||||
|
||||
.tooltip-heading {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function TooltipWithChildren({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>{message}</div>
|
||||
<div className={styles.tooltipMessage}>{message}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ export function CloseButton({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.close, className, 'absolute top-4 right-5')}
|
||||
className={clsx(
|
||||
styles.close,
|
||||
className,
|
||||
'absolute top-4 right-5 close-button'
|
||||
)}
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
×
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
width: 450px;
|
||||
display: inline-block;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props {
|
||||
onDismiss?(): void;
|
||||
'aria-label'?: string;
|
||||
'aria-labelledby'?: string;
|
||||
size?: 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
@@ -28,6 +30,8 @@ export function Modal({
|
||||
onDismiss,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
size = 'md',
|
||||
className,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<Context.Provider value>
|
||||
@@ -43,9 +47,12 @@ export function Modal({
|
||||
<DialogContent
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={clsx(styles.modalDialog, 'p-0 bg-transparent')}
|
||||
className={clsx(styles.modalDialog, 'p-0 bg-transparent', {
|
||||
'w-[450px]': size === 'md',
|
||||
'w-[700px]': size === 'lg',
|
||||
})}
|
||||
>
|
||||
<div className={clsx(styles.modalContent, 'relative')}>
|
||||
<div className={clsx(styles.modalContent, 'relative', className)}>
|
||||
{children}
|
||||
{onDismiss && <CloseButton onClose={onDismiss} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { EditorType } from '@/react/edge/edge-stacks/types';
|
||||
|
||||
import { BoxSelector } from '@@/BoxSelector';
|
||||
import { BoxSelectorOption } from '@@/BoxSelector/types';
|
||||
import {
|
||||
compose,
|
||||
kubernetes,
|
||||
} from '@@/BoxSelector/common-options/deployment-methods';
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
onChange(value: number): void;
|
||||
hasDockerEndpoint: boolean;
|
||||
hasKubeEndpoint: boolean;
|
||||
allowKubeToSelectCompose?: boolean;
|
||||
}
|
||||
|
||||
export function EdgeStackDeploymentTypeSelector({
|
||||
value,
|
||||
onChange,
|
||||
hasDockerEndpoint,
|
||||
hasKubeEndpoint,
|
||||
allowKubeToSelectCompose,
|
||||
}: Props) {
|
||||
const deploymentOptions: BoxSelectorOption<number>[] = [
|
||||
{
|
||||
...compose,
|
||||
value: EditorType.Compose,
|
||||
disabled: () => (allowKubeToSelectCompose ? false : hasKubeEndpoint),
|
||||
tooltip: () =>
|
||||
hasKubeEndpoint
|
||||
? 'Cannot use this option with Edge Kubernetes environments'
|
||||
: '',
|
||||
},
|
||||
{
|
||||
...kubernetes,
|
||||
value: EditorType.Kubernetes,
|
||||
disabled: () => hasDockerEndpoint,
|
||||
tooltip: () =>
|
||||
hasDockerEndpoint
|
||||
? 'Cannot use this option with Edge Docker environments'
|
||||
: '',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-sm-12 form-section-title"> Deployment type</div>
|
||||
<BoxSelector
|
||||
radioName="deploymentType"
|
||||
value={value}
|
||||
options={deploymentOptions}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
40
app/react/edge/edge-stacks/utils.test.ts
Normal file
40
app/react/edge/edge-stacks/utils.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
|
||||
import { EditorType } from './types';
|
||||
import { getValidEditorTypes } from './utils';
|
||||
|
||||
interface GetValidEditorTypesTest {
|
||||
endpointTypes: EnvironmentType[];
|
||||
expected: EditorType[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
describe('getValidEditorTypes', () => {
|
||||
const tests: GetValidEditorTypesTest[] = [
|
||||
{
|
||||
endpointTypes: [EnvironmentType.EdgeAgentOnDocker],
|
||||
expected: [EditorType.Compose],
|
||||
title: 'should return compose for docker envs',
|
||||
},
|
||||
{
|
||||
endpointTypes: [EnvironmentType.EdgeAgentOnKubernetes],
|
||||
expected: [EditorType.Kubernetes],
|
||||
title: 'should return kubernetes for kubernetes envs',
|
||||
},
|
||||
{
|
||||
endpointTypes: [
|
||||
EnvironmentType.EdgeAgentOnDocker,
|
||||
EnvironmentType.EdgeAgentOnKubernetes,
|
||||
],
|
||||
expected: [],
|
||||
title: 'should return empty for docker and kubernetes envs',
|
||||
},
|
||||
];
|
||||
|
||||
tests.forEach((test) => {
|
||||
// eslint-disable-next-line jest/valid-title
|
||||
it(test.title, () => {
|
||||
expect(getValidEditorTypes(test.endpointTypes)).toEqual(test.expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
app/react/edge/edge-stacks/utils.ts
Normal file
21
app/react/edge/edge-stacks/utils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||
|
||||
import { EditorType } from './types';
|
||||
|
||||
export function getValidEditorTypes(
|
||||
endpointTypes: EnvironmentType[],
|
||||
allowKubeToSelectCompose?: boolean
|
||||
) {
|
||||
const right: Partial<Record<EnvironmentType, EditorType[]>> = {
|
||||
[EnvironmentType.EdgeAgentOnDocker]: [EditorType.Compose],
|
||||
[EnvironmentType.EdgeAgentOnKubernetes]: allowKubeToSelectCompose
|
||||
? [EditorType.Kubernetes, EditorType.Compose]
|
||||
: [EditorType.Kubernetes],
|
||||
};
|
||||
|
||||
return endpointTypes.length
|
||||
? _.intersection(...endpointTypes.map((type) => right[type]))
|
||||
: [EditorType.Compose, EditorType.Kubernetes];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||
import { PublicSettingsViewModel } from '@/portainer/models/settings';
|
||||
import { PublicSettingsResponse } from '@/react/portainer/settings/types';
|
||||
|
||||
export function useHasHeartbeat(environment: Environment) {
|
||||
const associated = !!environment.EdgeID;
|
||||
@@ -30,7 +30,7 @@ export function useHasHeartbeat(environment: Environment) {
|
||||
|
||||
function getCheckinInterval(
|
||||
environment: Environment,
|
||||
settings: PublicSettingsViewModel
|
||||
settings: PublicSettingsResponse
|
||||
) {
|
||||
const asyncMode = environment.Edge.AsyncMode;
|
||||
|
||||
|
||||
@@ -4,16 +4,14 @@ import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
PropsWithChildren,
|
||||
} from 'react';
|
||||
|
||||
import { isAdmin } from '@/portainer/users/user.helpers';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { getUser } from '@/portainer/users/user.service';
|
||||
import { User, UserId } from '@/portainer/users/types';
|
||||
import { User } from '@/portainer/users/types';
|
||||
import { useUser as useLoadUser } from '@/portainer/users/queries/useUser';
|
||||
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
@@ -24,7 +22,12 @@ interface State {
|
||||
export const UserContext = createContext<State | null>(null);
|
||||
UserContext.displayName = 'UserContext';
|
||||
|
||||
export function useUser() {
|
||||
/**
|
||||
* @deprecated use `useCurrentUser` instead
|
||||
*/
|
||||
export const useUser = useCurrentUser;
|
||||
|
||||
export function useCurrentUser() {
|
||||
const context = useContext(UserContext);
|
||||
|
||||
if (context === null) {
|
||||
@@ -147,23 +150,19 @@ interface UserProviderProps {
|
||||
|
||||
export function UserProvider({ children }: UserProviderProps) {
|
||||
const [jwt] = useLocalStorage('JWT', '');
|
||||
const [user, setUser] = useState<User>();
|
||||
|
||||
useEffect(() => {
|
||||
if (jwt !== '') {
|
||||
const tokenPayload = jwtDecode(jwt) as { id: number };
|
||||
const tokenPayload = useMemo(() => jwtDecode(jwt) as { id: number }, [jwt]);
|
||||
|
||||
loadUser(tokenPayload.id);
|
||||
}
|
||||
}, [jwt]);
|
||||
const userQuery = useLoadUser(tokenPayload.id, {
|
||||
staleTime: Infinity, // should reload te user details only on page load
|
||||
});
|
||||
|
||||
const providerState = useMemo(() => ({ user }), [user]);
|
||||
const providerState = useMemo(
|
||||
() => ({ user: userQuery.data }),
|
||||
[userQuery.data]
|
||||
);
|
||||
|
||||
if (jwt === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!providerState.user) {
|
||||
if (jwt === '' || !providerState.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -172,9 +171,4 @@ export function UserProvider({ children }: UserProviderProps) {
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
async function loadUser(id: UserId) {
|
||||
const user = await getUser(id);
|
||||
setUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Link } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { useSettings } from '@/react/portainer/settings/queries';
|
||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||
import { Query } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
import { isEdgeEnvironment } from '@/react/portainer/environments/utils';
|
||||
|
||||
@@ -18,11 +18,10 @@ export function AMTButton({
|
||||
envQueryParams: Query;
|
||||
}) {
|
||||
const [isOpenDialog, setOpenDialog] = useState(false);
|
||||
const isOpenAmtEnabledQuery = useSettings(
|
||||
(settings) =>
|
||||
settings.EnableEdgeComputeFeatures &&
|
||||
settings.openAMTConfiguration.enabled
|
||||
);
|
||||
const isOpenAmtEnabledQuery = usePublicSettings({
|
||||
select: (settings) =>
|
||||
settings.EnableEdgeComputeFeatures && settings.IsAMTEnabled,
|
||||
});
|
||||
|
||||
const isOpenAMTEnabled = !!isOpenAmtEnabledQuery.data;
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ export function EditButtons({ environment }: { environment: Environment }) {
|
||||
const isEdgeAsync = checkEdgeAsync(environment);
|
||||
|
||||
const configRoute = getConfigRoute(environment);
|
||||
|
||||
const buttonsClasses = clsx(
|
||||
'w-full h-full !ml-0 !rounded-none',
|
||||
'hover:bg-gray-3 th-dark:hover:bg-gray-9 th-highcontrast:hover:bg-white'
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonsGrid className="w-11 ml-3">
|
||||
<LinkButton
|
||||
@@ -29,7 +35,7 @@ export function EditButtons({ environment }: { environment: Environment }) {
|
||||
color="none"
|
||||
icon={Edit2}
|
||||
size="medium"
|
||||
className="w-full h-full !ml-0 hover:bg-gray-3 !rounded-none"
|
||||
className={buttonsClasses}
|
||||
title="Edit"
|
||||
/>
|
||||
|
||||
@@ -40,7 +46,7 @@ export function EditButtons({ environment }: { environment: Environment }) {
|
||||
color="none"
|
||||
icon={Settings}
|
||||
size="medium"
|
||||
className="w-full h-full !ml-0 hover:bg-gray-3 !rounded-none"
|
||||
className={buttonsClasses}
|
||||
title="Configuration"
|
||||
/>
|
||||
</ButtonsGrid>
|
||||
@@ -79,7 +85,9 @@ function ButtonsGrid({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'grid border border-solid border-gray-5 rounded-r-lg',
|
||||
'grid border border-solid rounded-r-lg',
|
||||
'border-gray-5 th-dark:border-gray-9 th-highcontrast:border-white',
|
||||
'overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -87,7 +95,7 @@ function ButtonsGrid({
|
||||
<div
|
||||
key={index}
|
||||
className={clsx({
|
||||
'border-0 border-b border-solid border-b-gray-5':
|
||||
'border-0 border-b border-solid border-b-inherit':
|
||||
index < children.length - 1,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { History, Wifi, WifiOff } from 'lucide-react';
|
||||
import { History, Wifi, WifiOff, X } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import {
|
||||
@@ -9,52 +10,80 @@ import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { LinkButton } from '@@/LinkButton';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
type BrowseStatus = 'snapshot' | 'connected' | 'disconnected';
|
||||
|
||||
export function EnvironmentBrowseButtons({
|
||||
environment,
|
||||
onClickBrowse,
|
||||
onClickDisconnect,
|
||||
isActive,
|
||||
}: {
|
||||
environment: Environment;
|
||||
onClickBrowse(): void;
|
||||
onClickDisconnect(): void;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const isEdgeAsync = checkEdgeAsync(environment);
|
||||
const browseStatus = getStatus(isActive, isEdgeAsync);
|
||||
|
||||
const dashboardRoute = getDashboardRoute(environment);
|
||||
return (
|
||||
<div className="flex flex-col gap-1 justify-center [&>*]:h-1/3 h-24">
|
||||
{isBE && (
|
||||
<div className="flex flex-col gap-2 justify-center [&>*]:h-1/3 h-24 w-full">
|
||||
{isBE &&
|
||||
(browseStatus !== 'snapshot' ? (
|
||||
<LinkButton
|
||||
icon={History}
|
||||
disabled={!isEdgeAsync}
|
||||
to="edge.browse.dashboard"
|
||||
params={{
|
||||
environmentId: environment.Id,
|
||||
}}
|
||||
size="medium"
|
||||
color="light"
|
||||
className="w-full !py-0 !m-0"
|
||||
>
|
||||
Browse snapshot
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Button
|
||||
icon={X}
|
||||
onClick={onClickDisconnect}
|
||||
className="w-full !py-0 !m-0 opacity-60"
|
||||
size="medium"
|
||||
color="light"
|
||||
>
|
||||
Close snapshot
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{browseStatus !== 'connected' ? (
|
||||
<LinkButton
|
||||
icon={History}
|
||||
disabled={!isEdgeAsync || browseStatus === 'snapshot'}
|
||||
to="edge.browse.dashboard"
|
||||
params={{
|
||||
environmentId: environment.Id,
|
||||
}}
|
||||
color="light"
|
||||
title="Live connection is not available for async environments"
|
||||
icon={Wifi}
|
||||
disabled={isEdgeAsync}
|
||||
to={dashboardRoute.to}
|
||||
params={dashboardRoute.params}
|
||||
size="medium"
|
||||
onClick={onClickBrowse}
|
||||
color="primary"
|
||||
className="w-full !py-0 !m-0"
|
||||
>
|
||||
Browse snapshot
|
||||
Live connect
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Button
|
||||
icon={WifiOff}
|
||||
onClick={onClickDisconnect}
|
||||
className="w-full !py-0 !m-0 opacity-60"
|
||||
size="medium"
|
||||
color="primary"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<LinkButton
|
||||
title="Live connection is not available for async environments"
|
||||
icon={Wifi}
|
||||
disabled={isEdgeAsync || browseStatus === 'connected'}
|
||||
to={getDashboardRoute(environment)}
|
||||
params={{
|
||||
endpointId: environment.Id,
|
||||
}}
|
||||
onClick={onClickBrowse}
|
||||
color="primary"
|
||||
className="w-full !py-0 !m-0"
|
||||
>
|
||||
Live connect
|
||||
</LinkButton>
|
||||
|
||||
<BrowseStatusTag status={browseStatus} />
|
||||
</div>
|
||||
);
|
||||
@@ -87,7 +116,7 @@ function BrowseStatusTag({ status }: { status: BrowseStatus }) {
|
||||
|
||||
function Disconnected() {
|
||||
return (
|
||||
<div className="vertical-center justify-center opacity-50">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<Icon icon={WifiOff} />
|
||||
Disconnected
|
||||
</div>
|
||||
@@ -96,8 +125,14 @@ function Disconnected() {
|
||||
|
||||
function Connected() {
|
||||
return (
|
||||
<div className="vertical-center gap-2 justify-center text-green-8 bg-green-3 rounded-lg">
|
||||
<div className="rounded-full h-2 w-2 bg-green-8" />
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-2 justify-center rounded-lg',
|
||||
'text-green-8 th-dark:text-green-4',
|
||||
'bg-green-3 th-dark:bg-green-3/30'
|
||||
)}
|
||||
>
|
||||
<div className="rounded-full h-2 w-2 bg-green-8 th-dark:bg-green-4" />
|
||||
Connected
|
||||
</div>
|
||||
);
|
||||
@@ -105,7 +140,13 @@ function Connected() {
|
||||
|
||||
function Snapshot() {
|
||||
return (
|
||||
<div className="vertical-center gap-2 justify-center text-warning-7 bg-warning-3 rounded-lg">
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-2 justify-center rounded-lg',
|
||||
'text-warning-7 th-dark:text-warning-4',
|
||||
'bg-warning-3 th-dark:bg-warning-3/10 th-highcontrast:bg-warning-3/30'
|
||||
)}
|
||||
>
|
||||
<div className="rounded-full h-2 w-2 bg-warning-7" />
|
||||
Browsing Snapshot
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ function Template({ environment }: Args) {
|
||||
<EnvironmentItem
|
||||
environment={environment}
|
||||
onClickBrowse={() => {}}
|
||||
onClickDisconnect={() => {}}
|
||||
isActive={false}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ function renderComponent(
|
||||
<EnvironmentItem
|
||||
isActive={false}
|
||||
onClickBrowse={() => {}}
|
||||
onClickDisconnect={() => {}}
|
||||
environment={env}
|
||||
groupName={group.Name}
|
||||
/>
|
||||
|
||||
@@ -33,12 +33,14 @@ interface Props {
|
||||
environment: Environment;
|
||||
groupName?: string;
|
||||
onClickBrowse(): void;
|
||||
onClickDisconnect(): void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function EnvironmentItem({
|
||||
environment,
|
||||
onClickBrowse,
|
||||
onClickDisconnect,
|
||||
groupName,
|
||||
isActive,
|
||||
}: Props) {
|
||||
@@ -47,15 +49,13 @@ export function EnvironmentItem({
|
||||
const snapshotTime = getSnapshotTime(environment);
|
||||
|
||||
const tags = useEnvironmentTagNames(environment.TagIds);
|
||||
const dashboardRoute = getDashboardRoute(environment);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Link
|
||||
to={getDashboardRoute(environment)}
|
||||
params={{
|
||||
endpointId: environment.Id,
|
||||
environmentId: environment.Id,
|
||||
}}
|
||||
to={dashboardRoute.to}
|
||||
params={dashboardRoute.params}
|
||||
className="no-link"
|
||||
>
|
||||
<button
|
||||
@@ -115,10 +115,11 @@ export function EnvironmentItem({
|
||||
see https://stackoverflow.com/questions/66409964/warning-validatedomnesting-a-cannot-appear-as-a-descendant-of-a
|
||||
*/}
|
||||
<div className="absolute inset-y-0 right-0 flex justify-end w-56">
|
||||
<div className="py-3 flex items-center">
|
||||
<div className="py-3 flex items-center flex-1">
|
||||
<EnvironmentBrowseButtons
|
||||
environment={environment}
|
||||
onClickBrowse={onClickBrowse}
|
||||
onClickDisconnect={onClickDisconnect}
|
||||
isActive={isActive}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ const storageKey = 'home_endpoints';
|
||||
|
||||
export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
|
||||
const { isAdmin } = useUser();
|
||||
const { environmentId: currentEnvironmentId } = useStore(environmentStore);
|
||||
const currentEnvStore = useStore(environmentStore);
|
||||
|
||||
const [platformTypes, setPlatformTypes] = useHomePageFilter<
|
||||
Filter<PlatformType>[]
|
||||
@@ -140,110 +140,112 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
|
||||
return (
|
||||
<>
|
||||
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<TableContainer>
|
||||
<div className="px-4">
|
||||
<TableTitle
|
||||
className="!px-0"
|
||||
icon={HardDrive}
|
||||
label="Environments"
|
||||
description={
|
||||
<div className="w-full text-sm text-gray-7">
|
||||
Click on an environment to manage
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<SearchBar
|
||||
className="!bg-transparent !m-0"
|
||||
value={searchBarValue}
|
||||
onChange={setSearchBarValue}
|
||||
placeholder="Search by name, group, tag, status, URL..."
|
||||
data-cy="home-endpointsSearchInput"
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
data-cy="home-refreshEndpointsButton"
|
||||
size="medium"
|
||||
color="light"
|
||||
icon={RefreshCcw}
|
||||
className="!m-0"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
<KubeconfigButton
|
||||
environments={environments}
|
||||
envQueryParams={queryWithSort}
|
||||
/>
|
||||
|
||||
<AMTButton
|
||||
environments={environments}
|
||||
envQueryParams={queryWithSort}
|
||||
/>
|
||||
<TableContainer>
|
||||
<div className="px-4">
|
||||
<TableTitle
|
||||
className="!px-0"
|
||||
icon={HardDrive}
|
||||
label="Environments"
|
||||
description={
|
||||
<div className="w-full text-sm text-gray-7">
|
||||
Click on an environment to manage
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<SearchBar
|
||||
className="!bg-transparent !m-0 !min-w-[350px]"
|
||||
value={searchBarValue}
|
||||
onChange={setSearchBarValue}
|
||||
placeholder="Search by name, group, tag, status, URL..."
|
||||
data-cy="home-endpointsSearchInput"
|
||||
/>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
onClick={onRefresh}
|
||||
data-cy="home-refreshEndpointsButton"
|
||||
size="medium"
|
||||
color="light"
|
||||
icon={RefreshCcw}
|
||||
className="!m-0"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
<KubeconfigButton
|
||||
environments={environments}
|
||||
envQueryParams={queryWithSort}
|
||||
/>
|
||||
|
||||
{updateAvailable && <UpdateBadge />}
|
||||
</div>
|
||||
</TableTitle>
|
||||
<div className="-mt-3">
|
||||
<EnvironmentListFilters
|
||||
setPlatformTypes={setPlatformTypes}
|
||||
platformTypes={platformTypes}
|
||||
setConnectionTypes={setConnectionTypes}
|
||||
connectionTypes={connectionTypes}
|
||||
statusOnChange={statusOnChange}
|
||||
statusState={statusState}
|
||||
tagOnChange={tagOnChange}
|
||||
tagState={tagState}
|
||||
groupOnChange={groupOnChange}
|
||||
groupState={groupState}
|
||||
setAgentVersions={setAgentVersions}
|
||||
agentVersions={agentVersions}
|
||||
clearFilter={clearFilter}
|
||||
sortOnchange={sortOnchange}
|
||||
sortOnDescending={sortOnDescending}
|
||||
sortByDescending={sortByDescending}
|
||||
sortByButton={sortByButton}
|
||||
sortByState={sortByState}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="blocklist !p-0 mt-5 !space-y-2"
|
||||
data-cy="home-endpointList"
|
||||
>
|
||||
{renderItems(
|
||||
isLoading,
|
||||
totalCount,
|
||||
environments.map((env) => (
|
||||
<EnvironmentItem
|
||||
key={env.Id}
|
||||
environment={env}
|
||||
groupName={
|
||||
groupsQuery.data?.find((g) => g.Id === env.GroupId)
|
||||
?.Name
|
||||
}
|
||||
onClickBrowse={() => onClickBrowse(env)}
|
||||
isActive={env.Id === currentEnvironmentId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<TableFooter>
|
||||
<PaginationControls
|
||||
showAll={totalCount <= 100}
|
||||
pageLimit={pageLimit}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
totalCount={totalCount}
|
||||
onPageLimitChange={setPageLimit}
|
||||
/>
|
||||
</TableFooter>
|
||||
<AMTButton
|
||||
environments={environments}
|
||||
envQueryParams={queryWithSort}
|
||||
/>
|
||||
|
||||
{updateAvailable && <UpdateBadge />}
|
||||
</div>
|
||||
</TableContainer>
|
||||
</TableTitle>
|
||||
<div className="-mt-3">
|
||||
<EnvironmentListFilters
|
||||
setPlatformTypes={setPlatformTypes}
|
||||
platformTypes={platformTypes}
|
||||
setConnectionTypes={setConnectionTypes}
|
||||
connectionTypes={connectionTypes}
|
||||
statusOnChange={statusOnChange}
|
||||
statusState={statusState}
|
||||
tagOnChange={tagOnChange}
|
||||
tagState={tagState}
|
||||
groupOnChange={groupOnChange}
|
||||
groupState={groupState}
|
||||
setAgentVersions={setAgentVersions}
|
||||
agentVersions={agentVersions}
|
||||
clearFilter={clearFilter}
|
||||
sortOnchange={sortOnchange}
|
||||
sortOnDescending={sortOnDescending}
|
||||
sortByDescending={sortByDescending}
|
||||
sortByButton={sortByButton}
|
||||
sortByState={sortByState}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="blocklist !p-0 mt-5 !space-y-2"
|
||||
data-cy="home-endpointList"
|
||||
>
|
||||
{renderItems(
|
||||
isLoading,
|
||||
totalCount,
|
||||
environments.map((env) => (
|
||||
<EnvironmentItem
|
||||
key={env.Id}
|
||||
environment={env}
|
||||
groupName={
|
||||
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name
|
||||
}
|
||||
onClickBrowse={() => onClickBrowse(env)}
|
||||
onClickDisconnect={() =>
|
||||
env.Id === currentEnvStore.environmentId
|
||||
? currentEnvStore.clear()
|
||||
: null
|
||||
}
|
||||
isActive={env.Id === currentEnvStore.environmentId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<TableFooter>
|
||||
<PaginationControls
|
||||
className="!mr-0"
|
||||
showAll={totalCount <= 100}
|
||||
pageLimit={pageLimit}
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
totalCount={totalCount}
|
||||
onPageLimitChange={setPageLimit}
|
||||
/>
|
||||
</TableFooter>
|
||||
</div>
|
||||
</div>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -19,7 +19,11 @@ export interface Props {
|
||||
export function KubeconfigButton({ environments, envQueryParams }: Props) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (!isKubeconfigButtonVisible(environments)) {
|
||||
const kubeEnvs = environments.filter((env) =>
|
||||
isKubernetesEnvironment(env.Type)
|
||||
);
|
||||
|
||||
if (!isKubeconfigButtonVisible()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -29,10 +33,8 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) {
|
||||
onClick={handleClick}
|
||||
size="medium"
|
||||
className="!m-0"
|
||||
disabled={environments.some(
|
||||
(env) => !isKubernetesEnvironment(env.Type)
|
||||
)}
|
||||
icon={Download}
|
||||
disabled={kubeEnvs.length === 0}
|
||||
color="light"
|
||||
>
|
||||
Kubeconfig
|
||||
@@ -57,11 +59,8 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
function isKubeconfigButtonVisible(environments: Environment[]) {
|
||||
if (window.location.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
return environments.some((env) => isKubernetesEnvironment(env.Type));
|
||||
function isKubeconfigButtonVisible() {
|
||||
return window.location.protocol === 'https:';
|
||||
}
|
||||
|
||||
function prompt() {
|
||||
@@ -70,7 +69,7 @@ export function KubeconfigButton({ environments, envQueryParams }: Props) {
|
||||
<KubeconfigPrompt
|
||||
envQueryParams={envQueryParams}
|
||||
onClose={handleClose}
|
||||
selectedItems={environments.map((env) => env.Id)}
|
||||
selectedItems={kubeEnvs.map((env) => env.Id)}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||
import { isoDate } from '@/portainer/filters/filters';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget } from '@@/Widget';
|
||||
@@ -21,6 +20,7 @@ import { useList } from '../queries/list';
|
||||
import { NameField } from '../common/NameField';
|
||||
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||
import { BetaAlert } from '../common/BetaAlert';
|
||||
import { defaultValue } from '../common/ScheduledTimeField';
|
||||
|
||||
export default withLimitToBE(CreateView);
|
||||
|
||||
@@ -30,7 +30,7 @@ function CreateView() {
|
||||
groupIds: [],
|
||||
type: ScheduleType.Update,
|
||||
version: '',
|
||||
scheduledTime: isoDate(Date.now() + 24 * 60 * 60 * 1000),
|
||||
scheduledTime: defaultValue(),
|
||||
};
|
||||
|
||||
const schedulesQuery = useList();
|
||||
|
||||
@@ -25,6 +25,10 @@ export function ScheduledTimeField({ disabled }: Props) {
|
||||
|
||||
const dateValue = useMemo(() => parseIsoDate(value), [value]);
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl label="Schedule date & time" errors={error}>
|
||||
{!disabled ? (
|
||||
@@ -64,6 +68,10 @@ export function timeValidation() {
|
||||
);
|
||||
}
|
||||
|
||||
export function defaultValue() {
|
||||
return isoDate(Date.now() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function isValidDate(date: Date) {
|
||||
return date instanceof Date && !Number.isNaN(date.valueOf());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useFormikContext } from 'formik';
|
||||
import semverCompare from 'semver-compare';
|
||||
import _ from 'lodash';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
@@ -10,10 +11,10 @@ import { TextTip } from '@@/Tip/TextTip';
|
||||
import { FormValues } from './types';
|
||||
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
|
||||
import { VersionSelect } from './VersionSelect';
|
||||
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||
import { defaultValue, ScheduledTimeField } from './ScheduledTimeField';
|
||||
|
||||
export function UpdateScheduleDetailsFieldset() {
|
||||
const { values } = useFormikContext<FormValues>();
|
||||
const { values, setFieldValue } = useFormikContext<FormValues>();
|
||||
|
||||
const environmentIdsQuery = useEdgeGroupsEnvironmentIds(values.groupIds);
|
||||
|
||||
@@ -29,6 +30,14 @@ export function UpdateScheduleDetailsFieldset() {
|
||||
const hasNoTimeZone = environments.some((env) => !env.LocalTimeZone);
|
||||
const hasTimeZone = environments.some((env) => env.LocalTimeZone);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasTimeZone) {
|
||||
setFieldValue('scheduledTime', '');
|
||||
} else if (!values.scheduledTime) {
|
||||
setFieldValue('scheduledTime', defaultValue());
|
||||
}
|
||||
}, [setFieldValue, hasTimeZone, values.scheduledTime]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{edgeGroupsEnvironmentIds.length > 0 ? (
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import { array, object, string } from 'yup';
|
||||
import { array, object, SchemaOf, string } from 'yup';
|
||||
|
||||
import { parseIsoDate } from '@/portainer/filters/filters';
|
||||
|
||||
import { EdgeUpdateSchedule, ScheduleType } from '../types';
|
||||
|
||||
import { nameValidation } from './NameField';
|
||||
import { typeValidation } from './ScheduleTypeSelector';
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function validation(
|
||||
schedules: EdgeUpdateSchedule[],
|
||||
currentId?: EdgeUpdateSchedule['id']
|
||||
) {
|
||||
): SchemaOf<FormValues> {
|
||||
return object({
|
||||
groupIds: array().min(1, 'At least one group is required'),
|
||||
name: nameValidation(schedules, currentId),
|
||||
type: typeValidation(),
|
||||
// time: number()
|
||||
// .min(Date.now() / 1000)
|
||||
// .required(),
|
||||
version: string().when('type', {
|
||||
is: ScheduleType.Update,
|
||||
// update type
|
||||
then: (schema) => schema.required('Version is required'),
|
||||
// rollback
|
||||
otherwise: (schema) => schema.required('No rollback options available'),
|
||||
}),
|
||||
scheduledTime: string()
|
||||
.default('')
|
||||
.test('valid', (value) => !value || parseIsoDate(value) !== null),
|
||||
version: string()
|
||||
.default('')
|
||||
.when('type', {
|
||||
is: ScheduleType.Update,
|
||||
// update type
|
||||
then: (schema) => schema.required('Version is required'),
|
||||
// rollback
|
||||
otherwise: (schema) => schema.required('No rollback options available'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,24 +70,38 @@ export function isLocalEnvironment(environment: Environment) {
|
||||
export function getDashboardRoute(environment: Environment) {
|
||||
if (isEdgeEnvironment(environment.Type)) {
|
||||
if (!environment.EdgeID) {
|
||||
return 'portainer.endpoints.endpoint';
|
||||
return {
|
||||
to: 'portainer.endpoints.endpoint',
|
||||
params: { id: environment.Id },
|
||||
};
|
||||
}
|
||||
|
||||
if (isEdgeAsync(environment)) {
|
||||
return 'edge.browse.dashboard';
|
||||
return {
|
||||
to: 'edge.browse.dashboard',
|
||||
params: { environmentId: environment.Id },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const platform = getPlatformType(environment.Type);
|
||||
const params = { endpointId: environment.Id };
|
||||
const to = getPlatformRoute();
|
||||
|
||||
switch (platform) {
|
||||
case PlatformType.Azure:
|
||||
return 'azure.dashboard';
|
||||
case PlatformType.Docker:
|
||||
return 'docker.dashboard';
|
||||
case PlatformType.Kubernetes:
|
||||
return 'kubernetes.dashboard';
|
||||
default:
|
||||
return '';
|
||||
return { to, params };
|
||||
|
||||
function getPlatformRoute() {
|
||||
const platform = getPlatformType(environment.Type);
|
||||
switch (platform) {
|
||||
case PlatformType.Azure:
|
||||
return 'azure.dashboard';
|
||||
case PlatformType.Docker:
|
||||
return 'docker.dashboard';
|
||||
case PlatformType.Kubernetes:
|
||||
return 'kubernetes.dashboard';
|
||||
case PlatformType.Nomad:
|
||||
return 'nomad.dashboard';
|
||||
default:
|
||||
throw new Error(`Unsupported platform ${platform}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
.wizard-list-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 50px 1fr;
|
||||
grid-template-columns: 50px 1fr 70px;
|
||||
grid-template-areas:
|
||||
'image title'
|
||||
'image subtitle'
|
||||
'image type';
|
||||
'image title title'
|
||||
'image subtitle edgeStatus'
|
||||
'image type type';
|
||||
border: 1px solid rgb(221, 221, 221);
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
@@ -13,6 +13,13 @@
|
||||
box-shadow: 0 3px 10px -2px rgb(161 170 166 / 20%);
|
||||
}
|
||||
|
||||
.wizard-list-edge-status {
|
||||
grid-area: edgeStatus;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wizard-list-image {
|
||||
grid-area: image;
|
||||
font-size: 35px;
|
||||
|
||||
@@ -5,14 +5,13 @@ import { useCallback, useEffect } from 'react';
|
||||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useUpdateSettingsMutation } from '@/react/portainer/settings/queries';
|
||||
import { Settings } from '@/react/portainer/settings/types';
|
||||
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
||||
import { Settings } from '../types';
|
||||
|
||||
import { EnabledWaitingRoomSwitch } from './EnableWaitingRoomSwitch';
|
||||
|
||||
interface FormValues {
|
||||
|
||||
@@ -43,8 +43,13 @@ export function DeploymentSyncOptions() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialValues = {
|
||||
Edge: settingsQuery.data.Edge,
|
||||
const initialValues: FormValues = {
|
||||
Edge: {
|
||||
AsyncMode: settingsQuery.data.Edge.AsyncMode,
|
||||
CommandInterval: settingsQuery.data.Edge.CommandInterval,
|
||||
PingInterval: settingsQuery.data.Edge.PingInterval,
|
||||
SnapshotInterval: settingsQuery.data.Edge.SnapshotInterval,
|
||||
},
|
||||
EdgeAgentCheckinInterval: settingsQuery.data.EdgeAgentCheckinInterval,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { promptAsync } from '@/portainer/services/modal.service/prompt';
|
||||
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { usePublicSettings } from '../../queries';
|
||||
|
||||
enum DeployType {
|
||||
FDO = 'FDO',
|
||||
MANUAL = 'MANUAL',
|
||||
}
|
||||
|
||||
export function AddDeviceButton() {
|
||||
const router = useRouter();
|
||||
const isFDOEnabledQuery = usePublicSettings({
|
||||
select: (settings) => settings.IsFDOEnabled,
|
||||
});
|
||||
const isFDOEnabled = !!isFDOEnabledQuery.data;
|
||||
|
||||
return (
|
||||
<Button onClick={handleNewDeviceClick} icon={Plus}>
|
||||
Add Device
|
||||
</Button>
|
||||
);
|
||||
|
||||
async function handleNewDeviceClick() {
|
||||
const result = await getDeployType();
|
||||
|
||||
switch (result) {
|
||||
case DeployType.FDO:
|
||||
router.stateService.go('portainer.endpoints.importDevice');
|
||||
break;
|
||||
case DeployType.MANUAL:
|
||||
router.stateService.go('portainer.wizard.endpoints', {
|
||||
edgeDevice: true,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function getDeployType(): Promise<DeployType> {
|
||||
if (!isFDOEnabled) {
|
||||
return Promise.resolve(DeployType.MANUAL);
|
||||
}
|
||||
|
||||
return promptAsync({
|
||||
title: 'How would you like to add an Edge Device?',
|
||||
inputType: 'radio',
|
||||
inputOptions: [
|
||||
{
|
||||
text: 'Provision bare-metal using Intel FDO',
|
||||
value: DeployType.FDO,
|
||||
},
|
||||
{
|
||||
text: 'Deploy agent manually',
|
||||
value: DeployType.MANUAL,
|
||||
},
|
||||
],
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Confirm',
|
||||
className: 'btn-primary',
|
||||
},
|
||||
},
|
||||
}) as Promise<DeployType>;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Formik, Form } from 'formik';
|
||||
import { Laptop } from 'lucide-react';
|
||||
|
||||
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
|
||||
import { Settings } from '@/react/portainer/settings/types';
|
||||
|
||||
import { Switch } from '@@/form-components/SwitchField/Switch';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
@@ -10,15 +11,9 @@ import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
|
||||
import { Settings } from '../types';
|
||||
|
||||
import { validationSchema } from './EdgeComputeSettings.validation';
|
||||
|
||||
export interface FormValues {
|
||||
EdgeAgentCheckinInterval: number;
|
||||
EnableEdgeComputeFeatures: boolean;
|
||||
EnforceEdgeID: boolean;
|
||||
}
|
||||
import { FormValues } from './types';
|
||||
import { AddDeviceButton } from './AddDeviceButton';
|
||||
|
||||
interface Props {
|
||||
settings?: Settings;
|
||||
@@ -30,13 +25,29 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialValues: FormValues = {
|
||||
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures,
|
||||
EdgePortainerUrl: settings.EdgePortainerUrl,
|
||||
EdgeAgentCheckinInterval: settings.EdgeAgentCheckinInterval,
|
||||
EnforceEdgeID: settings.EnforceEdgeID,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<Widget>
|
||||
<WidgetTitle icon={Laptop} title="Edge Compute settings" />
|
||||
<WidgetTitle
|
||||
icon={Laptop}
|
||||
title={
|
||||
<>
|
||||
<span className="mr-3">Edge Compute settings</span>
|
||||
{settings.EnableEdgeComputeFeatures && <AddDeviceButton />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<WidgetBody>
|
||||
<Formik
|
||||
initialValues={settings}
|
||||
initialValues={initialValues}
|
||||
enableReinitialize
|
||||
validationSchema={() => validationSchema()}
|
||||
onSubmit={onSubmit}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export interface Settings {
|
||||
EdgeAgentCheckinInterval: number;
|
||||
export interface FormValues {
|
||||
EnableEdgeComputeFeatures: boolean;
|
||||
TrustOnFirstConnect: boolean;
|
||||
EnforceEdgeID: boolean;
|
||||
EdgePortainerUrl: string;
|
||||
EnforceEdgeID: boolean;
|
||||
EdgeAgentCheckinInterval: number;
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface Settings {
|
||||
EdgeAgentCheckinInterval: number;
|
||||
EnableEdgeComputeFeatures: boolean;
|
||||
TrustOnFirstConnect: boolean;
|
||||
EnforceEdgeID: boolean;
|
||||
EdgePortainerUrl: string;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
withError,
|
||||
withInvalidate,
|
||||
} from '@/react-tools/react-query';
|
||||
import { PublicSettingsViewModel } from '@/portainer/models/settings';
|
||||
|
||||
import {
|
||||
getSettings,
|
||||
@@ -13,18 +12,18 @@ import {
|
||||
getPublicSettings,
|
||||
updateDefaultRegistry,
|
||||
} from './settings.service';
|
||||
import { DefaultRegistry, Settings } from './types';
|
||||
import { DefaultRegistry, PublicSettingsResponse, Settings } from './types';
|
||||
|
||||
export function usePublicSettings<T = PublicSettingsViewModel>({
|
||||
export function usePublicSettings<T = PublicSettingsResponse>({
|
||||
enabled,
|
||||
select,
|
||||
onSuccess,
|
||||
}: {
|
||||
select?: (settings: PublicSettingsViewModel) => T;
|
||||
select?: (settings: PublicSettingsResponse) => T;
|
||||
enabled?: boolean;
|
||||
onSuccess?: (data: T) => void;
|
||||
} = {}) {
|
||||
return useQuery(['settings', 'public'], () => getPublicSettings(), {
|
||||
return useQuery(['settings', 'public'], getPublicSettings, {
|
||||
select,
|
||||
...withError('Unable to retrieve public settings'),
|
||||
enabled,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { PublicSettingsViewModel } from '@/portainer/models/settings';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { DefaultRegistry, PublicSettingsResponse, Settings } from './types';
|
||||
import { PublicSettingsResponse, DefaultRegistry, Settings } from './types';
|
||||
|
||||
export async function getPublicSettings() {
|
||||
try {
|
||||
const { data } = await axios.get<PublicSettingsResponse>(
|
||||
buildUrl('public')
|
||||
);
|
||||
return new PublicSettingsViewModel(data);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
@@ -29,7 +28,11 @@ export async function getSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSettings(settings: Partial<Settings>) {
|
||||
type OptionalSettings = Omit<Partial<Settings>, 'Edge'> & {
|
||||
Edge?: Partial<Settings['Edge']>;
|
||||
};
|
||||
|
||||
export async function updateSettings(settings: OptionalSettings) {
|
||||
try {
|
||||
await axios.put(buildUrl(), settings);
|
||||
} catch (e) {
|
||||
|
||||
@@ -134,26 +134,67 @@ export interface Settings {
|
||||
SnapshotInterval: number;
|
||||
CommandInterval: number;
|
||||
AsyncMode: boolean;
|
||||
TunnelServerAddress: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PublicSettingsResponse {
|
||||
// URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string
|
||||
LogoURL: string;
|
||||
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
|
||||
AuthenticationMethod: AuthenticationMethod;
|
||||
// Whether edge compute features are enabled
|
||||
EnableEdgeComputeFeatures: boolean;
|
||||
// Supported feature flags
|
||||
Features: Record<string, boolean>;
|
||||
// The URL used for oauth login
|
||||
OAuthLoginURI: string;
|
||||
// The URL used for oauth logout
|
||||
OAuthLogoutURI: string;
|
||||
// Whether portainer internal auth view will be hidden
|
||||
OAuthHideInternalAuth: boolean;
|
||||
// Whether telemetry is enabled
|
||||
EnableTelemetry: boolean;
|
||||
// The expiry of a Kubeconfig
|
||||
KubeconfigExpiry: string;
|
||||
interface GlobalDeploymentOptions {
|
||||
/** Hide manual deploy forms in portainer */
|
||||
hideAddWithForm: boolean;
|
||||
/** Configure this per environment or globally */
|
||||
perEnvOverride: boolean;
|
||||
/** Hide the web editor in the remaining visible forms */
|
||||
hideWebEditor: boolean;
|
||||
/** Hide the file upload option in the remaining visible forms */
|
||||
hideFileUpload: boolean;
|
||||
}
|
||||
|
||||
export interface PublicSettingsResponse {
|
||||
/** URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string */
|
||||
LogoURL: string;
|
||||
/** The content in plaintext used to display in the login page. Will hide when value is empty string (only on BE) */
|
||||
CustomLoginBanner: string;
|
||||
/** Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth */
|
||||
AuthenticationMethod: AuthenticationMethod;
|
||||
/** The minimum required length for a password of any user when using internal auth mode */
|
||||
RequiredPasswordLength: number;
|
||||
/** Deployment options for encouraging deployment as code (only on BE) */
|
||||
GlobalDeploymentOptions: GlobalDeploymentOptions;
|
||||
/** Whether edge compute features are enabled */
|
||||
EnableEdgeComputeFeatures: boolean;
|
||||
/** Supported feature flags */
|
||||
Features: { [key: Feature]: boolean };
|
||||
/** The URL used for oauth login */
|
||||
OAuthLoginURI: string;
|
||||
/** The URL used for oauth logout */
|
||||
OAuthLogoutURI: string;
|
||||
/** Whether portainer internal auth view will be hidden (only on BE) */
|
||||
OAuthHideInternalAuth: boolean;
|
||||
/** Whether telemetry is enabled */
|
||||
EnableTelemetry: boolean;
|
||||
/** The expiry of a Kubeconfig */
|
||||
KubeconfigExpiry: string;
|
||||
/** Whether team sync is enabled */
|
||||
TeamSync: boolean;
|
||||
/** Whether FDO is enabled */
|
||||
IsFDOEnabled: boolean;
|
||||
/** Whether AMT is enabled */
|
||||
IsAMTEnabled: boolean;
|
||||
|
||||
/** Whether to hide default registry (only on BE) */
|
||||
DefaultRegistry: {
|
||||
Hide: boolean;
|
||||
};
|
||||
Edge: {
|
||||
/** Whether the device has been started in edge async mode */
|
||||
AsyncMode: boolean;
|
||||
/** The ping interval for edge agent - used in edge async mode [seconds] */
|
||||
PingInterval: number;
|
||||
/** The snapshot interval for edge agent - used in edge async mode [seconds] */
|
||||
SnapshotInterval: number;
|
||||
/** The command list interval for edge agent - used in edge async mode [seconds] */
|
||||
CommandInterval: number;
|
||||
/** The check in interval for edge agent (in seconds) - used in non async mode [seconds] */
|
||||
CheckinInterval: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
||||
z-index: 999;
|
||||
z-index: 10;
|
||||
transition: all 0.4s ease 0s;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user