Compare commits

...

81 Commits

Author SHA1 Message Date
cheloRydel
3903fdbfd4 remove feature flag 2022-01-12 17:40:24 -03:00
cheloRydel
4c5a108965 fix(edge): fix Add New device visibility 2022-01-12 16:31:29 -03:00
cheloRydel
c9f71e2e33 merge upstream 2022-01-12 16:08:32 -03:00
cheloRydel
a43983cc1e fixed husky version 2022-01-12 16:01:13 -03:00
cheloRydel
6d5975b1f8 merge develop 2022-01-12 15:54:16 -03:00
cheloRydel
0ae8bc10f3 remove log 2022-01-12 14:48:34 -03:00
cheloRydel
259faec4ff fix ColumnVisibilityMenu columns prop 2022-01-12 13:15:02 -03:00
cheloRydel
f57c54e836 peer review in createEndpoint 2022-01-12 13:03:12 -03:00
cheloRydel
e5ee5e0bec peer review pt 3 2022-01-12 12:58:43 -03:00
cheloRydel
b597eb2618 update husky version 2022-01-11 17:07:03 -03:00
cheloRydel
3e0a7e64eb change selection and actions columns width 2022-01-11 16:58:18 -03:00
cheloRydel
7c36a275a8 peer review pt 2 2022-01-11 16:48:16 -03:00
cheloRydel
1e63ab33cb peer review pt 1 2022-01-11 15:24:50 -03:00
cheloRydel
cff5bb82c3 peer review pt 1 2022-01-11 15:22:44 -03:00
andres-portainer
f4c9cce60b fix(fdo): fix incorrect profile URL INT-45 (#6377) 2022-01-11 12:52:47 -03:00
cheloRydel
927d4d5ed4 try format 2022-01-11 11:38:24 -03:00
cheloRydel
28841f04bd try format 2022-01-11 11:37:59 -03:00
cheloRydel
29b35a6e3c fix labels 2022-01-11 09:44:11 -03:00
cheloRydel
1fa5dc2bb7 fix imports order 2022-01-11 09:39:23 -03:00
cheloRydel
0734aaaf45 fix import 2022-01-11 09:25:46 -03:00
cheloRydel
17a5df42f9 yarn format 2022-01-11 09:20:39 -03:00
cheloRydel
bdd2a5901c remove logs 2022-01-10 18:30:33 -03:00
cheloRydel
54491e3d51 fix open-amt feature flag value 2022-01-10 17:36:55 -03:00
cheloRydel
157f851920 improve styles 2022-01-10 17:26:19 -03:00
cheloRydel
5a4edbcf9f improve styles 2022-01-10 17:13:36 -03:00
cheloRydel
aabc20bd24 feat(edge): remove unused angular views 2022-01-10 16:50:15 -03:00
cheloRydel
6ca05eed22 fix useQuery 2022-01-10 16:26:13 -03:00
cheloRydel
3002711852 feat(edge): add AMT actions 2022-01-10 16:14:58 -03:00
cheloRydel
6eba0f5f53 feat(edge): add IsEdgeDevice attribute 2022-01-10 12:00:53 -03:00
cheloRydel
a75e8517c9 feat(edge): proper heartnbeat and group columns 2022-01-10 10:50:40 -03:00
cheloRydel
beb46cb767 add ActionsMenu component 2022-01-07 17:35:56 -03:00
andres-portainer
1be0694222 feat(fdo): add FDO profiles INT-22 (#6363)
feat(fdo): add FDO profiles INT-22
2022-01-07 14:46:29 -03:00
cheloRydel
db8e4e8c9a useQuery for amt devices 2022-01-07 14:03:53 -03:00
cheloRydel
9e20979e73 init load devices using react-query 2022-01-07 13:02:27 -03:00
cheloRydel
00f39db0c0 revert husky version update 2022-01-07 10:49:26 -03:00
cheloRydel
e1453c0ce7 parse amt devices values 2022-01-07 10:27:04 -03:00
cheloRydel
af30b4d088 merge upstream 2022-01-06 17:16:55 -03:00
cheloRydel
efe8c9f45f merge develop 2022-01-06 16:46:21 -03:00
cheloRydel
8b459adad4 feat(edge): init AMTDevicesDatatable in react show devices 2022-01-06 15:54:27 -03:00
cheloRydel
862a9f56ea feat(edge): init AMTDevicesDatatable in react 2022-01-06 14:15:25 -03:00
cheloRydel
5c8f5e840c feat(edge): init useExpand hook 2022-01-06 13:42:54 -03:00
cheloRydel
3260b057d8 feat(edge): add edge devices actions 2022-01-06 12:06:25 -03:00
cheloRydel
7ecfb89c71 fix state column 2022-01-05 19:31:12 -03:00
cheloRydel
352de4a436 fix module 2022-01-05 19:25:15 -03:00
cheloRydel
42a850331b move files 2022-01-05 19:10:54 -03:00
cheloRydel
595b1db085 init add edge devices table view 2022-01-05 18:47:29 -03:00
cheloRydel
09478b56a0 init add edge devices view 2022-01-05 14:30:51 -03:00
Marcelo Rydel
e9113865dd refactor(intel): Add Edge Compute Settings view (#6351) 2022-01-05 13:17:05 -03:00
cheloRydel
1440ae68fa Merge branch 'develop' into feat/poc-intel 2022-01-05 12:29:26 -03:00
cheloRydel
9bbc2f24ef merge develop 2022-01-04 11:36:38 -03:00
cheloRydel
bb5ffb7cc9 Merge branch 'develop' into feat/poc-intel 2022-01-03 19:18:36 -03:00
andres-portainer
ca2259270e fix(fdo): move the FDO client code to the hostmanagement folder INT-44 (#6345) 2022-01-03 11:52:36 -03:00
andres-portainer
fce6fd27c9 Minor code cleanup. 2021-12-24 11:50:50 -03:00
cheloRydel
f0a197b648 Merge branch 'develop' into feat/poc-intel 2021-12-23 10:25:57 -03:00
Marcelo Rydel
80000806e1 feat(openmt): use .ts services with axios for OpenAMT (#6312) 2021-12-23 10:22:56 -03:00
cheloRydel
ec170ae2b4 merge develop 2021-12-21 10:16:39 -03:00
cheloRydel
a73977c321 merge develop 2021-12-20 15:24:10 -03:00
cheloRydel
f8c1f6ee11 merge develop 2021-12-20 10:15:40 -03:00
Marcelo Rydel
e1f7411926 feat(openamt): change kvm redirection for pop up, always enable features [INT-37] (#6293) 2021-12-17 17:07:58 -03:00
Marcelo Rydel
2fcd238320 feat(openamt): change kvm redirection for pop up, always enable features [INT-37] (#6292) 2021-12-17 12:35:48 -03:00
cheloRydel
24b5fce26d yarn install 2021-12-17 11:00:59 -03:00
Marcelo Rydel
ea49a192da feat(openamt): Remove wireless config related code [INT-41] (#6291) 2021-12-16 16:26:38 -03:00
Marcelo Rydel
11e486019a feat(openamt): Better UI/UX for AMT activation loading [INT-39] (#6290) 2021-12-16 16:01:49 -03:00
Marcelo Rydel
1af028df4f Merge branch 'develop' into feat/poc-intel 2021-12-16 09:32:43 -03:00
Marcelo Rydel
a84ec025e8 feat(openamt): preload existing AMT settings (#6283) 2021-12-16 09:29:03 -03:00
Marcelo Rydel
6dfe8ad97a fix(intel): Fix switches params (#6282) 2021-12-15 10:05:04 -03:00
Marcelo Rydel
a6d9e566ba feat(openamt): Do not fetch OpenAMT details for an unassociated Edge endpoint (#6273) 2021-12-15 09:32:31 -03:00
deviantony
867168cac7 refactor(fdo): fix develop merge issues 2021-12-15 10:07:00 +00:00
Anthony Lapenna
184db846c2 Merge branch 'develop' into feat/poc-intel 2021-12-15 09:54:52 +00:00
Marcelo Rydel
738ec4316d feat(fdo): add import device UI [INT-20] (#6240)
feat(fdo): add import device UI INT-20
2021-12-14 19:51:16 -03:00
Anthony Lapenna
8567c4051a Merge branch 'develop' into feat/poc-intel 2021-12-13 07:27:19 +00:00
Marcelo Rydel
415af981f8 feat(openamt): Disable the ability to use KVM and OOB actions on a MPS disconnected device [INT-36] (#6254) 2021-12-10 17:05:38 -03:00
Marcelo Rydel
3acaee1489 feat(openamt): Increase OpenAMT timeouts [INT-30] (#6253) 2021-12-11 07:45:12 +13:00
Marcelo Rydel
27ced894fd feat(openamt): hide wireless config in OpenAMT form (#6250) 2021-12-09 17:15:57 -03:00
andres-portainer
cdf954a5e5 feat(fdo): implement Owner client INT-17 (#6231)
feat(fdo): implement Owner client INT-17
2021-12-08 19:33:23 -03:00
andres-portainer
dbe17b9425 feat(fdo): implement the FDO configuration settings INT-19 (#6238)
feat(fdo): implement the FDO configuration settings INT-19
2021-12-08 15:08:42 -03:00
Marcelo Rydel
b36a0ec258 feat(openamt): Enable KVM by default [INT-25] (#6228) 2021-12-07 09:14:16 -03:00
Marcelo Rydel
7ddea7e09e feat(openamt): Enhance the Environments MX to activate OpenAMT on compatible environments [INT-7] (#6196) 2021-12-03 15:27:52 -03:00
Marcelo Rydel
4173702662 feat(openamt): add AMT Devices KVM Connection [INT-10] (#6179) 2021-12-03 13:00:59 -03:00
Marcelo Rydel
11268e7816 feat(openamt): add AMT Devices Ouf of Band Managamenet actions [INT-9] (#6171) 2021-12-03 12:44:51 -03:00
Marcelo Rydel
e2bb76ff58 feat(openamt): add AMT Devices information in Environments view [INT-8] (#6169) 2021-12-03 12:26:53 -03:00
156 changed files with 8988 additions and 6080 deletions

View File

@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert"
)
const jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","Credentials":{"MPSUser":"","MPSPassword":"","MPSToken":""},"DomainConfiguration":{"CertFileText":"","CertPassword":"","DomainName":""},"WirelessConfiguration":null},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
const jsonobject = `{"LogoURL":"","BlackListedLabels":[],"AuthenticationMethod":1,"LDAPSettings":{"AnonymousMode":true,"ReaderDN":"","URL":"","TLSConfig":{"TLS":false,"TLSSkipVerify":false},"StartTLS":false,"SearchSettings":[{"BaseDN":"","Filter":"","UserNameAttribute":""}],"GroupSearchSettings":[{"GroupBaseDN":"","GroupFilter":"","GroupAttribute":""}],"AutoCreateUsers":true},"OAuthSettings":{"ClientID":"","AccessTokenURI":"","AuthorizationURI":"","ResourceURI":"","RedirectURI":"","UserIdentifier":"","Scopes":"","OAuthAutoCreateUsers":false,"DefaultTeamID":0,"SSO":true,"LogoutURI":"","KubeSecretKey":"j0zLVtY/lAWBk62ByyF0uP80SOXaitsABP0TTJX8MhI="},"OpenAMTConfiguration":{"Enabled":false,"MPSServer":"","MPSUser":"","MPSPassword":"","MPSToken":"","CertFileContent":"","CertFileName":"","CertFilePassword":"","DomainName":""},"FeatureFlagSettings":{},"SnapshotInterval":"5m","TemplatesURL":"https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json","EdgeAgentCheckinInterval":5,"EnableEdgeComputeFeatures":false,"UserSessionTimeout":"8h","KubeconfigExpiry":"0","EnableTelemetry":true,"HelmRepositoryURL":"https://charts.bitnami.com/bitnami","KubectlShellImage":"portainer/kubectl-shell","DisplayDonationHeader":false,"DisplayExternalContributors":false,"EnableHostManagementFeatures":false,"AllowVolumeBrowserForRegularUsers":false,"AllowBindMountsForRegularUsers":false,"AllowPrivilegedModeForRegularUsers":false,"AllowHostNamespaceForRegularUsers":false,"AllowStackManagementForRegularUsers":false,"AllowDeviceMappingForRegularUsers":false,"AllowContainerCapabilitiesForRegularUsers":false}`
func Test_MarshalObject(t *testing.T) {
is := assert.New(t)

View File

@@ -15,7 +15,7 @@ import (
var errUnsupportedEnvironmentType = errors.New("Environment not supported")
const (
defaultDockerRequestTimeout = 60
defaultDockerRequestTimeout = 60 * time.Second
dockerClientVersion = "1.37"
)
@@ -33,22 +33,23 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers
}
}
// createClient is a generic function to create a Docker client based on
// CreateClient is a generic function to create a Docker client based on
// a specific environment(endpoint) configuration. The nodeName parameter can be used
// with an agent enabled environment(endpoint) to target a specific node in an agent cluster.
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) {
// The underlying http client timeout may be specified, a default value is used otherwise.
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*client.Client, error) {
if endpoint.Type == portainer.AzureEnvironment {
return nil, errUnsupportedEnvironmentType
} else if endpoint.Type == portainer.AgentOnDockerEnvironment {
return createAgentClient(endpoint, factory.signatureService, nodeName)
return createAgentClient(endpoint, factory.signatureService, nodeName, timeout)
} else if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName)
return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName, timeout)
}
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
return createLocalClient(endpoint)
}
return createTCPClient(endpoint)
return createTCPClient(endpoint, timeout)
}
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
@@ -58,8 +59,8 @@ func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
)
}
func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
httpCli, err := httpClient(endpoint)
func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) {
httpCli, err := httpClient(endpoint, timeout)
if err != nil {
return nil, err
}
@@ -71,8 +72,8 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
)
}
func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string) (*client.Client, error) {
httpCli, err := httpClient(endpoint)
func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string, timeout *time.Duration) (*client.Client, error) {
httpCli, err := httpClient(endpoint, timeout)
if err != nil {
return nil, err
}
@@ -95,7 +96,7 @@ func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.D
if err != nil {
return nil, err
}
endpointURL := fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
return client.NewClientWithOpts(
@@ -106,8 +107,8 @@ func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.D
)
}
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string) (*client.Client, error) {
httpCli, err := httpClient(endpoint)
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
httpCli, err := httpClient(endpoint, timeout)
if err != nil {
return nil, err
}
@@ -134,7 +135,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
)
}
func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
transport := &http.Transport{}
if endpoint.TLSConfig.TLS {
@@ -145,8 +146,13 @@ func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
transport.TLSClientConfig = tlsConfig
}
clientTimeout := defaultDockerRequestTimeout
if timeout != nil {
clientTimeout = *timeout
}
return &http.Client{
Transport: transport,
Timeout: defaultDockerRequestTimeout * time.Second,
Timeout: clientTimeout,
}, nil
}

View File

@@ -26,7 +26,7 @@ func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter {
// CreateSnapshot creates a snapshot of a specific Docker environment(endpoint)
func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) {
cli, err := snapshotter.clientFactory.CreateClient(endpoint, "")
cli, err := snapshotter.clientFactory.CreateClient(endpoint, "", nil)
if err != nil {
return nil, err
}

View File

@@ -14,6 +14,7 @@ require (
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/docker/cli v20.10.9+incompatible
github.com/docker/docker v20.10.9+incompatible
github.com/fxamacker/cbor/v2 v2.3.0
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
github.com/go-git/go-git/v5 v5.3.0
github.com/go-ldap/ldap/v3 v3.1.8
@@ -33,6 +34,7 @@ require (
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.7.0

View File

@@ -300,6 +300,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fullsailor/pkcs7 v0.0.0-20190404230743-d7302db945fa/go.mod h1:KnogPXtdwXqoenmZCw6S+25EAm2MkxbG0deNDu4cbSA=
github.com/fxamacker/cbor/v2 v2.3.0 h1:aM45YGMctNakddNNAezPxDUpv38j44Abh+hifNuqXik=
github.com/fxamacker/cbor/v2 v2.3.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo=
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU=
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI=
github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY=
@@ -649,6 +651,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777 h1:rDj3WeO+TiWyxfcydUnKegWAZoR5kQsnW0wzhggdOrw=
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777/go.mod h1:xRVvTK+cS/dJSvrOufGUQFWfgvE7yXExeng96n8377o=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
@@ -722,6 +726,8 @@ github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr3+MjI=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=

View File

@@ -0,0 +1,235 @@
package fdo
import (
"bytes"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/rkl-/digest"
)
type FDOOwnerClient struct {
OwnerURL string
Username string
Password string
Timeout time.Duration
}
type ServiceInfo struct {
Module string
Var string
Filename string
Bytes []byte
GUID string
Device string
Priority int
OS string
Version string
Arch string
CRID int
Hash string
}
func (c FDOOwnerClient) doDigestAuthReq(method, endpoint, contentType string, body io.Reader) (*http.Response, error) {
transport := digest.NewTransport(c.Username, c.Password)
client, err := transport.Client()
if err != nil {
return nil, err
}
client.Timeout = c.Timeout
e, err := url.Parse(endpoint)
if err != nil {
return nil, err
}
u, err := url.Parse(c.OwnerURL)
if err != nil {
return nil, err
}
req, err := http.NewRequest(method, u.ResolveReference(e).String(), body)
if err != nil {
return nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
return client.Do(req)
}
func (c FDOOwnerClient) PostVoucher(ov []byte) (string, error) {
resp, err := c.doDigestAuthReq(
http.MethodPost,
"api/v1/owner/vouchers",
"application/cbor",
bytes.NewReader(ov),
)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", errors.New(http.StatusText(resp.StatusCode))
}
return string(body), nil
}
func (c FDOOwnerClient) PutDeviceSVI(info ServiceInfo) error {
values := url.Values{}
values.Set("module", info.Module)
values.Set("var", info.Var)
values.Set("filename", info.Filename)
values.Set("guid", info.GUID)
values.Set("device", info.Device)
values.Set("priority", strconv.Itoa(info.Priority))
values.Set("os", info.OS)
values.Set("version", info.Version)
values.Set("arch", info.Arch)
values.Set("crid", strconv.Itoa(info.CRID))
values.Set("hash", info.Hash)
resp, err := c.doDigestAuthReq(
http.MethodPut,
"api/v1/device/svi?"+values.Encode(),
"application/octet-stream",
strings.NewReader(string(info.Bytes)),
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))
}
return nil
}
func (c FDOOwnerClient) PutDeviceSVIRaw(info url.Values, body []byte) error {
resp, err := c.doDigestAuthReq(
http.MethodPut,
"api/v1/device/svi?"+info.Encode(),
"application/octet-stream",
strings.NewReader(string(body)),
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))
}
return nil
}
func (c FDOOwnerClient) GetVouchers() ([]string, error) {
resp, err := c.doDigestAuthReq(
http.MethodGet,
"api/v1/owner/vouchers",
"",
nil,
)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New(http.StatusText(resp.StatusCode))
}
contents, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
guids := strings.FieldsFunc(
strings.TrimSpace(string(contents)),
func(c rune) bool {
return c == ','
},
)
return guids, nil
}
func (c FDOOwnerClient) DeleteVoucher(guid string) error {
resp, err := c.doDigestAuthReq(
http.MethodDelete,
"api/v1/owner/vouchers?id="+guid,
"",
nil,
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))
}
return nil
}
func (c FDOOwnerClient) GetDeviceSVI(guid string) (string, error) {
resp, err := c.doDigestAuthReq(
http.MethodGet,
"api/v1/device/svi?guid="+guid,
"",
nil,
)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", errors.New(http.StatusText(resp.StatusCode))
}
return string(body), nil
}
func (c FDOOwnerClient) DeleteDeviceSVI(id string) error {
resp, err := c.doDigestAuthReq(
http.MethodDelete,
"api/v1/device/svi?id="+id,
"",
nil,
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.New(http.StatusText(resp.StatusCode))
}
return nil
}

View File

@@ -14,39 +14,39 @@ type authenticationResponse struct {
Token string `json:"token"`
}
func (service *Service) executeAuthenticationRequest(configuration portainer.OpenAMTConfiguration) (*authenticationResponse, error) {
func (service *Service) Authorization(configuration portainer.OpenAMTConfiguration) (string, error) {
loginURL := fmt.Sprintf("https://%s/mps/login/api/v1/authorize", configuration.MPSServer)
payload := map[string]string{
"username": configuration.Credentials.MPSUser,
"password": configuration.Credentials.MPSPassword,
"username": configuration.MPSUser,
"password": configuration.MPSPassword,
}
jsonValue, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, loginURL, bytes.NewBuffer(jsonValue))
if err != nil {
return nil, err
return "", err
}
req.Header.Set("Content-Type", "application/json")
response, err := service.httpsClient.Do(req)
if err != nil {
return nil, err
return "", err
}
responseBody, readErr := ioutil.ReadAll(response.Body)
if readErr != nil {
return nil, readErr
return "", readErr
}
errorResponse := parseError(responseBody)
if errorResponse != nil {
return nil, errorResponse
return "", errorResponse
}
var token authenticationResponse
err = json.Unmarshal(responseBody, &token)
if err != nil {
return nil, err
return "", err
}
return &token, nil
return token.Token, nil
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
@@ -47,7 +46,7 @@ func (service *Service) createOrUpdateCIRAConfig(configuration portainer.OpenAMT
func (service *Service) getCIRAConfig(configuration portainer.OpenAMTConfiguration, configName string) (*CIRAConfig, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/ciraconfigs/%s", configuration.MPSServer, configName)
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
@@ -89,7 +88,7 @@ func (service *Service) saveCIRAConfig(method string, configuration portainer.Op
}
payload, _ := json.Marshal(config)
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload)
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil {
return nil, err
}
@@ -123,7 +122,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
if err != nil {
return "", err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", configuration.Credentials.MPSToken))
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", configuration.MPSToken))
response, err := service.httpsClient.Do(req)
if err != nil {
@@ -131,7 +130,7 @@ func (service *Service) getCIRACertificate(configuration portainer.OpenAMTConfig
}
if response.StatusCode != http.StatusOK {
return "", errors.New(fmt.Sprintf("unexpected status code %s", response.Status))
return "", fmt.Errorf("unexpected status code %s", response.Status)
}
certificate, err := io.ReadAll(response.Body)

View File

@@ -0,0 +1,87 @@
package openamt
import (
"encoding/json"
"fmt"
"strings"
portainer "github.com/portainer/portainer/api"
)
type Device struct {
GUID string `json:"guid"`
HostName string `json:"hostname"`
ConnectionStatus bool `json:"connectionStatus"`
}
type DevicePowerState struct {
State portainer.PowerState `json:"powerstate"`
}
type DeviceEnabledFeatures struct {
Redirection bool `json:"redirection"`
KVM bool `json:"KVM"`
SOL bool `json:"SOL"`
IDER bool `json:"IDER"`
UserConsent string `json:"userConsent"`
}
func (service *Service) getDevice(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*Device, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/devices/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
if strings.EqualFold(err.Error(), "invalid value") {
return nil, nil
}
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result Device
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) getDevicePowerState(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*DevicePowerState, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/power/state/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result DevicePowerState
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) getDeviceEnabledFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*DeviceEnabledFeatures, error) {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/features/%s", configuration.MPSServer, deviceGUID)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result DeviceEnabledFeatures
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}

View File

@@ -37,9 +37,9 @@ func (service *Service) createOrUpdateDomain(configuration portainer.OpenAMTConf
}
func (service *Service) getDomain(configuration portainer.OpenAMTConfiguration) (*Domain, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains/%s", configuration.MPSServer, configuration.DomainConfiguration.DomainName)
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains/%s", configuration.MPSServer, configuration.DomainName)
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
@@ -59,15 +59,15 @@ func (service *Service) saveDomain(method string, configuration portainer.OpenAM
url := fmt.Sprintf("https://%s/rps/api/v1/admin/domains", configuration.MPSServer)
profile := Domain{
DomainName: configuration.DomainConfiguration.DomainName,
DomainSuffix: configuration.DomainConfiguration.DomainName,
ProvisioningCert: configuration.DomainConfiguration.CertFileText,
ProvisioningCertPassword: configuration.DomainConfiguration.CertPassword,
DomainName: configuration.DomainName,
DomainSuffix: configuration.DomainName,
ProvisioningCert: configuration.CertFileContent,
ProvisioningCertPassword: configuration.CertFilePassword,
ProvisioningCertStorageFormat: "string",
}
payload, _ := json.Marshal(profile)
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload)
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil {
return nil, err
}

View File

@@ -29,7 +29,7 @@ type (
}
)
func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string, wirelessConfig string) (*Profile, error) {
func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
profile, err := service.getAMTProfile(configuration, profileName)
if err != nil {
return nil, err
@@ -40,7 +40,7 @@ func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMT
method = http.MethodPatch
}
profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName, wirelessConfig)
profile, err = service.saveAMTProfile(method, configuration, profileName, ciraConfigName)
if err != nil {
return nil, err
}
@@ -50,7 +50,7 @@ func (service *Service) createOrUpdateAMTProfile(configuration portainer.OpenAMT
func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfiguration, profileName string) (*Profile, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles/%s", configuration.MPSServer, profileName)
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken)
responseBody, err := service.executeGetRequest(url, configuration.MPSToken)
if err != nil {
return nil, err
}
@@ -66,7 +66,7 @@ func (service *Service) getAMTProfile(configuration portainer.OpenAMTConfigurati
return &result, nil
}
func (service *Service) saveAMTProfile(method string, configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string, wirelessConfig string) (*Profile, error) {
func (service *Service) saveAMTProfile(method string, configuration portainer.OpenAMTConfiguration, profileName string, ciraConfigName string) (*Profile, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/profiles", configuration.MPSServer)
profile := Profile{
@@ -74,23 +74,15 @@ func (service *Service) saveAMTProfile(method string, configuration portainer.Op
Activation: "acmactivate",
GenerateRandomAMTPassword: false,
GenerateRandomMEBxPassword: false,
AMTPassword: configuration.Credentials.MPSPassword,
MEBXPassword: configuration.Credentials.MPSPassword,
AMTPassword: configuration.MPSPassword,
MEBXPassword: configuration.MPSPassword,
CIRAConfigName: &ciraConfigName,
Tags: []string{},
DHCPEnabled: true,
}
if wirelessConfig != "" {
profile.WIFIConfigs = []ProfileWifiConfig{
{
Priority: 1,
ProfileName: DefaultWirelessConfigName,
},
}
}
payload, _ := json.Marshal(profile)
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload)
responseBody, err := service.executeSaveRequest(method, url, configuration.MPSToken, payload)
if err != nil {
return nil, err
}

View File

@@ -1,91 +0,0 @@
package openamt
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
)
type (
WirelessProfile struct {
ProfileName string `json:"profileName"`
AuthenticationMethod int `json:"authenticationMethod"`
EncryptionMethod int `json:"encryptionMethod"`
SSID string `json:"ssid"`
PSKPassphrase string `json:"pskPassphrase"`
}
)
func (service *Service) createOrUpdateWirelessConfig(configuration portainer.OpenAMTConfiguration, wirelessConfigName string) (*WirelessProfile, error) {
wirelessConfig, err := service.getWirelessConfig(configuration, wirelessConfigName)
if err != nil {
return nil, err
}
method := http.MethodPost
if wirelessConfig != nil {
method = http.MethodPatch
}
wirelessConfig, err = service.saveWirelessConfig(method, configuration, wirelessConfigName)
if err != nil {
return nil, err
}
return wirelessConfig, nil
}
func (service *Service) getWirelessConfig(configuration portainer.OpenAMTConfiguration, configName string) (*WirelessProfile, error) {
url := fmt.Sprintf("https://%s/rps/api/v1/admin/wirelessconfigs/%s", configuration.MPSServer, configName)
responseBody, err := service.executeGetRequest(url, configuration.Credentials.MPSToken)
if err != nil {
return nil, err
}
if responseBody == nil {
return nil, nil
}
var result WirelessProfile
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}
func (service *Service) saveWirelessConfig(method string, configuration portainer.OpenAMTConfiguration, configName string) (*WirelessProfile, error) {
parsedAuthenticationMethod, err := strconv.Atoi(configuration.WirelessConfiguration.AuthenticationMethod)
if err != nil {
return nil, fmt.Errorf("error parsing wireless authentication method: %s", err.Error())
}
parsedEncryptionMethod, err := strconv.Atoi(configuration.WirelessConfiguration.EncryptionMethod)
if err != nil {
return nil, fmt.Errorf("error parsing wireless encryption method: %s", err.Error())
}
url := fmt.Sprintf("https://%s/rps/api/v1/admin/wirelessconfigs", configuration.MPSServer)
config := WirelessProfile{
ProfileName: configName,
AuthenticationMethod: parsedAuthenticationMethod,
EncryptionMethod: parsedEncryptionMethod,
SSID: configuration.WirelessConfiguration.SSID,
PSKPassphrase: configuration.WirelessConfiguration.PskPass,
}
payload, _ := json.Marshal(config)
responseBody, err := service.executeSaveRequest(method, url, configuration.Credentials.MPSToken, payload)
if err != nil {
return nil, err
}
var result WirelessProfile
err = json.Unmarshal(responseBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}

View File

@@ -0,0 +1,55 @@
package openamt
import (
"encoding/json"
"fmt"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
)
type ActionResponse struct {
Body struct {
ReturnValue int `json:"ReturnValue"`
ReturnValueStr string `json:"ReturnValueStr"`
} `json:"Body"`
}
func (service *Service) executeDeviceAction(configuration portainer.OpenAMTConfiguration, deviceGUID string, action int) error {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/power/action/%s", configuration.MPSServer, deviceGUID)
payload := map[string]int{
"action": action,
}
jsonValue, _ := json.Marshal(payload)
responseBody, err := service.executeSaveRequest(http.MethodPost, url, configuration.MPSToken, jsonValue)
if err != nil {
return err
}
var response ActionResponse
err = json.Unmarshal(responseBody, &response)
if err != nil {
return err
}
if response.Body.ReturnValue != 0 {
return fmt.Errorf("failed to execute action, error status %v: %s", response.Body.ReturnValue, response.Body.ReturnValueStr)
}
return nil
}
func parseAction(actionRaw string) (portainer.PowerState, error) {
switch strings.ToLower(actionRaw) {
case "power on":
return powerOnState, nil
case "power off":
return powerOffState, nil
case "restart":
return restartState, nil
}
return 0, fmt.Errorf("unsupported device action %s", actionRaw)
}

View File

@@ -0,0 +1,29 @@
package openamt
import (
"encoding/json"
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
)
func (service *Service) enableDeviceFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string, features portainer.OpenAMTDeviceEnabledFeatures) error {
url := fmt.Sprintf("https://%s/mps/api/v1/amt/features/%s", configuration.MPSServer, deviceGUID)
payload := map[string]interface{}{
"enableSOL": features.SOL,
"enableIDER": features.IDER,
"enableKVM": features.KVM,
"redirection": features.Redirection,
"userConsent": features.UserConsent,
}
jsonValue, _ := json.Marshal(payload)
_, err := service.executeSaveRequest(http.MethodPost, url, configuration.MPSToken, jsonValue)
if err != nil {
return err
}
return nil
}

View File

@@ -12,12 +12,18 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"golang.org/x/sync/errgroup"
)
const (
DefaultCIRAConfigName = "ciraConfigDefault"
DefaultWirelessConfigName = "wirelessProfileDefault"
DefaultProfileName = "profileAMTDefault"
DefaultCIRAConfigName = "ciraConfigDefault"
DefaultProfileName = "profileAMTDefault"
httpClientTimeout = 5 * time.Minute
powerOnState portainer.PowerState = 2
powerOffState portainer.PowerState = 8
restartState portainer.PowerState = 5
)
// Service represents a service for managing an OpenAMT server.
@@ -27,12 +33,9 @@ type Service struct {
// NewService initializes a new service.
func NewService(dataStore dataservices.DataStore) *Service {
if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) {
return nil
}
return &Service{
httpsClient: &http.Client{
Timeout: time.Second * time.Duration(5),
Timeout: httpClientTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
@@ -63,28 +66,19 @@ func parseError(responseBody []byte) error {
return nil
}
func (service *Service) ConfigureDefault(configuration portainer.OpenAMTConfiguration) error {
token, err := service.executeAuthenticationRequest(configuration)
func (service *Service) Configure(configuration portainer.OpenAMTConfiguration) error {
token, err := service.Authorization(configuration)
if err != nil {
return err
}
configuration.Credentials.MPSToken = token.Token
configuration.MPSToken = token
ciraConfig, err := service.createOrUpdateCIRAConfig(configuration, DefaultCIRAConfigName)
if err != nil {
return err
}
wirelessConfigName := ""
if configuration.WirelessConfiguration != nil {
wirelessConfig, err := service.createOrUpdateWirelessConfig(configuration, DefaultWirelessConfigName)
if err != nil {
return err
}
wirelessConfigName = wirelessConfig.ProfileName
}
_, err = service.createOrUpdateAMTProfile(configuration, DefaultProfileName, ciraConfig.ConfigName, wirelessConfigName)
_, err = service.createOrUpdateAMTProfile(configuration, DefaultProfileName, ciraConfig.ConfigName)
if err != nil {
return err
}
@@ -119,7 +113,7 @@ func (service *Service) executeSaveRequest(method string, url string, token stri
if errorResponse != nil {
return nil, errorResponse
}
return nil, errors.New(fmt.Sprintf("unexpected status code %s", response.Status))
return nil, fmt.Errorf("unexpected status code %s", response.Status)
}
return responseBody, nil
@@ -151,8 +145,110 @@ func (service *Service) executeGetRequest(url string, token string) ([]byte, err
if errorResponse != nil {
return nil, errorResponse
}
return nil, errors.New(fmt.Sprintf("unexpected status code %s", response.Status))
return nil, fmt.Errorf("unexpected status code %s", response.Status)
}
return responseBody, nil
}
func (service *Service) DeviceInformation(configuration portainer.OpenAMTConfiguration, deviceGUID string) (*portainer.OpenAMTDeviceInformation, error) {
token, err := service.Authorization(configuration)
if err != nil {
return nil, err
}
configuration.MPSToken = token
var g errgroup.Group
var resultDevice *Device
var resultPowerState *DevicePowerState
var resultEnabledFeatures *DeviceEnabledFeatures
g.Go(func() error {
device, err := service.getDevice(configuration, deviceGUID)
if err != nil {
return err
}
if device == nil {
return fmt.Errorf("device %s not found", deviceGUID)
}
resultDevice = device
return nil
})
g.Go(func() error {
powerState, err := service.getDevicePowerState(configuration, deviceGUID)
if err != nil {
return err
}
resultPowerState = powerState
return nil
})
g.Go(func() error {
enabledFeatures, err := service.getDeviceEnabledFeatures(configuration, deviceGUID)
if err != nil {
return err
}
resultEnabledFeatures = enabledFeatures
return nil
})
if err := g.Wait(); err != nil {
return nil, err
}
deviceInformation := &portainer.OpenAMTDeviceInformation{
GUID: resultDevice.GUID,
HostName: resultDevice.HostName,
ConnectionStatus: resultDevice.ConnectionStatus,
}
if resultPowerState != nil {
deviceInformation.PowerState = resultPowerState.State
}
if resultEnabledFeatures != nil {
deviceInformation.EnabledFeatures = &portainer.OpenAMTDeviceEnabledFeatures{
Redirection: resultEnabledFeatures.Redirection,
KVM: resultEnabledFeatures.KVM,
SOL: resultEnabledFeatures.SOL,
IDER: resultEnabledFeatures.IDER,
UserConsent: resultEnabledFeatures.UserConsent,
}
}
return deviceInformation, nil
}
func (service *Service) ExecuteDeviceAction(configuration portainer.OpenAMTConfiguration, deviceGUID string, action string) error {
parsedAction, err := parseAction(action)
if err != nil {
return err
}
token, err := service.Authorization(configuration)
if err != nil {
return err
}
configuration.MPSToken = token
err = service.executeDeviceAction(configuration, deviceGUID, int(parsedAction))
if err != nil {
return err
}
return nil
}
func (service *Service) EnableDeviceFeatures(configuration portainer.OpenAMTConfiguration, deviceGUID string, features portainer.OpenAMTDeviceEnabledFeatures) (string, error) {
token, err := service.Authorization(configuration)
if err != nil {
return "", err
}
configuration.MPSToken = token
err = service.enableDeviceFeatures(configuration, deviceGUID, features)
if err != nil {
return "", err
}
return token, nil
}

View File

@@ -37,6 +37,7 @@ type endpointCreatePayload struct {
AzureAuthenticationKey string
TagIDs []portainer.TagID
EdgeCheckinInterval int
IsEdgeDevice bool
}
type endpointCreationEnum int
@@ -144,6 +145,9 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true)
payload.EdgeCheckinInterval = checkinInterval
isEdgeDevice, _ := request.RetrieveBooleanMultiPartFormValue(r, "IsEdgeDevice", true)
payload.IsEdgeDevice = isEdgeDevice
return nil
}
@@ -332,6 +336,7 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
EdgeKey: edgeKey,
EdgeCheckinInterval: payload.EdgeCheckinInterval,
Kubernetes: portainer.KubernetesDefault(),
IsEdgeDevice: payload.IsEdgeDevice,
}
err = handler.saveEndpointAndUpdateAuthorizations(endpoint)
@@ -370,6 +375,7 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.DockerSnapshot{},
Kubernetes: portainer.KubernetesDefault(),
IsEdgeDevice: payload.IsEdgeDevice,
}
err := handler.snapshotAndPersistEndpoint(endpoint)
@@ -435,6 +441,7 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload,
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.DockerSnapshot{},
Kubernetes: portainer.KubernetesDefault(),
IsEdgeDevice: payload.IsEdgeDevice,
}
err := handler.storeTLSFiles(endpoint, payload)

View File

@@ -89,6 +89,9 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID))
}
edgeDeviceFilter, _ := request.RetrieveBooleanQueryParameter(r, "edgeDeviceFilter", true)
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
if search != "" {
tags, err := handler.DataStore.Tag().Tags()
if err != nil {
@@ -231,6 +234,17 @@ func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int)
return filteredEndpoints
}
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilter bool) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if edgeDeviceFilter == endpoint.IsEdgeDevice {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
tags := make([]string, 0)
for _, tagID := range tagIDs {

View File

@@ -17,6 +17,7 @@ import (
"github.com/portainer/portainer/api/http/handler/endpoints"
"github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/helm"
"github.com/portainer/portainer/api/http/handler/hostmanagement/fdo"
"github.com/portainer/portainer/api/http/handler/hostmanagement/openamt"
"github.com/portainer/portainer/api/http/handler/kubernetes"
"github.com/portainer/portainer/api/http/handler/ldap"
@@ -64,6 +65,7 @@ type Handler struct {
SettingsHandler *settings.Handler
SSLHandler *ssl.Handler
OpenAMTHandler *openamt.Handler
FDOHandler *fdo.Handler
StackHandler *stacks.Handler
StatusHandler *status.Handler
StorybookHandler *storybook.Handler
@@ -231,6 +233,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.OpenAMTHandler != nil {
http.StripPrefix("/api", h.OpenAMTHandler).ServeHTTP(w, r)
}
case strings.HasPrefix(r.URL.Path, "/api/fdo"):
if h.FDOHandler != nil {
http.StripPrefix("/api", h.FDOHandler).ServeHTTP(w, r)
}
case strings.HasPrefix(r.URL.Path, "/api/teams"):
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/team_memberships"):

View File

@@ -0,0 +1,179 @@
package fdo
import (
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
cbor "github.com/fxamacker/cbor/v2"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/sirupsen/logrus"
)
type deviceConfigurePayload struct {
EdgeKey string `json:"edgeKey"`
Name string `json:"name"`
ProfileURL string `json:"profile"`
}
func (payload *deviceConfigurePayload) Validate(r *http.Request) error {
if payload.EdgeKey == "" {
return errors.New("invalid edge key provided")
}
if payload.Name == "" {
return errors.New("the device name cannot be empty")
}
if err := validateURL(payload.ProfileURL); err != nil {
return fmt.Errorf("FDO profile URL: %w", err)
}
return nil
}
func fetchProfileContents(profileURL string) ([]byte, error) {
resp, err := http.Get(profileURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// @id fdoConfigureDevice
// @summary configure an FDO device
// @description configure an FDO device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param body body deviceConfigurePayload true "Device Configuration"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /fdo/configure/{guid} [post]
func (handler *Handler) fdoConfigureDevice(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
guid, err := request.RetrieveRouteVariableValue(r, "guid")
if err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: request.RetrieveRouteVariableValue()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: guid not found", Err: err}
}
var payload deviceConfigurePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
logrus.WithError(err).Error("Invalid request payload")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
profileUrl, err := url.Parse(payload.ProfileURL)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "fdoConfigureDevice: invalid FDO profile URL", Err: err}
}
profileContents, err := fetchProfileContents(payload.ProfileURL)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadGateway, Message: "fdoConfigureDevice: could not retrieve the FDO profile", Err: err}
}
fdoClient, err := handler.newFDOClient()
if err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: newFDOClient()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: newFDOClient()", Err: err}
}
// enable fdo_sys
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"0"},
"module": []string{"fdo_sys"},
"var": []string{"active"},
"bytes": []string{"F5"}, // this is "true" in CBOR
}, []byte("")); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
}
// write down the edgekey
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"1"},
"module": []string{"fdo_sys"},
"var": []string{"filedesc"},
"filename": []string{"DEVICE_edgekey.txt"},
}, []byte(payload.EdgeKey)); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw(edgekey)")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw(edgekey)", Err: err}
}
// write down the device name
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"1"},
"module": []string{"fdo_sys"},
"var": []string{"filedesc"},
"filename": []string{"DEVICE_name.txt"},
}, []byte(payload.Name)); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw(name)")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw(name)", Err: err}
}
// write down the device GUID - used as the EDGE_DEVICE_GUID too
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"1"},
"module": []string{"fdo_sys"},
"var": []string{"filedesc"},
"filename": []string{"DEVICE_GUID.txt"},
}, []byte(guid)); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
}
// onboarding script - this would get selected by the profile name
deploymentScriptName := path.Base(profileUrl.Path)
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"1"},
"module": []string{"fdo_sys"},
"var": []string{"filedesc"},
"filename": []string{deploymentScriptName},
}, profileContents); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
}
b, err := cbor.Marshal([]string{"/bin/sh", deploymentScriptName})
if err != nil {
logrus.WithError(err).Error("failed to marshal string to CBOR")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw() failed to encode", Err: err}
}
cbor := strings.ToUpper(hex.EncodeToString(b))
logrus.WithField("cbor", cbor).WithField("string", deploymentScriptName).Info("converted to CBOR")
if err = fdoClient.PutDeviceSVIRaw(url.Values{
"guid": []string{guid},
"priority": []string{"2"},
"module": []string{"fdo_sys"},
"var": []string{"exec"},
"bytes": []string{cbor},
}, []byte("")); err != nil {
logrus.WithError(err).Info("fdoConfigureDevice: PutDeviceSVIRaw()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoConfigureDevice: PutDeviceSVIRaw()", Err: err}
}
return response.Empty(w)
}

View File

@@ -0,0 +1,104 @@
package fdo
import (
"errors"
"fmt"
"net/http"
"net/url"
"time"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/hostmanagement/fdo"
"github.com/sirupsen/logrus"
)
type fdoConfigurePayload portainer.FDOConfiguration
func validateURL(u string) error {
p, err := url.Parse(u)
if err != nil {
return err
}
if p.Scheme != "http" && p.Scheme != "https" {
return errors.New("invalid scheme provided, must be 'http' or 'https'")
}
if p.Host == "" {
return errors.New("invalid host provided")
}
return nil
}
func (payload *fdoConfigurePayload) Validate(r *http.Request) error {
if payload.Enabled {
if err := validateURL(payload.OwnerURL); err != nil {
return fmt.Errorf("owner server URL: %w", err)
}
if err := validateURL(payload.ProfilesURL); err != nil {
return fmt.Errorf("profile list URL: %w", err)
}
}
return nil
}
func (handler *Handler) saveSettings(config portainer.FDOConfiguration) error {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return err
}
settings.FDOConfiguration = config
return handler.DataStore.Settings().UpdateSettings(settings)
}
func (handler *Handler) newFDOClient() (fdo.FDOOwnerClient, error) {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return fdo.FDOOwnerClient{}, err
}
return fdo.FDOOwnerClient{
OwnerURL: settings.FDOConfiguration.OwnerURL,
Username: settings.FDOConfiguration.OwnerUsername,
Password: settings.FDOConfiguration.OwnerPassword,
Timeout: 5 * time.Second,
}, nil
}
// @id fdoConfigure
// @summary Enable Portainer's FDO capabilities
// @description Enable Portainer's FDO capabilities
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @param body body fdoConfigurePayload true "FDO Settings"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /fdo [post]
func (handler *Handler) fdoConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload fdoConfigurePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
logrus.WithError(err).Error("Invalid request payload")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings := portainer.FDOConfiguration(payload)
if err = handler.saveSettings(settings); err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error saving FDO settings", Err: err}
}
return response.Empty(w)
}

View File

@@ -0,0 +1,31 @@
package fdo
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
)
type Handler struct {
*mux.Router
DataStore dataservices.DataStore
}
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) *Handler {
h := &Handler{
Router: mux.NewRouter(),
DataStore: dataStore,
}
h.Handle("/fdo/configure", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoConfigure))).Methods(http.MethodPost)
h.Handle("/fdo/list", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoListAll))).Methods(http.MethodGet)
h.Handle("/fdo/register", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoRegisterDevice))).Methods(http.MethodPost)
h.Handle("/fdo/configure/{guid}", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoConfigureDevice))).Methods(http.MethodPost)
h.Handle("/fdo/profiles", bouncer.AdminAccess(httperror.LoggerHandler(h.fdoProfiles))).Methods(http.MethodGet)
return h
}

View File

@@ -0,0 +1,39 @@
package fdo
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/sirupsen/logrus"
"github.com/portainer/libhttp/response"
)
// @id fdoListAll
// @summary List all known FDO vouchers
// @description List all known FDO vouchers
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /fdo/list [get]
func (handler *Handler) fdoListAll(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
fdoClient, err := handler.newFDOClient()
if err != nil {
logrus.WithError(err).Info("fdoListAll: newFDOClient()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: newFDOClient()", Err: err}
}
// Get all vouchers
guids, err := fdoClient.GetVouchers()
if err != nil {
logrus.WithError(err).Info("fdoListAll: GetVouchers()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoListAll: GetVouchers()", Err: err}
}
return response.JSON(w, guids)
}

View File

@@ -0,0 +1,40 @@
package fdo
import (
"io"
"net/http"
httperror "github.com/portainer/libhttp/error"
)
// @id fdoProfiles
// @summary retrieve a list of FDO profiles
// @description retrieve a list of FDO profiles
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @failure 500 "Bad gateway"
// @router /fdo/profiles [get]
func (handler *Handler) fdoProfiles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
profiles, err := http.Get(settings.FDOConfiguration.ProfilesURL)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadGateway, Message: "Unabled to retrieve the profile list", Err: err}
}
defer profiles.Body.Close()
if _, err := io.Copy(w, profiles.Body); err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadGateway, Message: "Unabled to retrieve the profile list", Err: err}
}
return nil
}

View File

@@ -0,0 +1,49 @@
package fdo
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/sirupsen/logrus"
)
type registerDeviceResponse struct {
Guid string `json:"guid" example:"c6ea3343-229a-4c07-9096-beef7134e1d3"`
}
// @id fdoRegisterDevice
// @summary register an FDO device
// @description register an FDO device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /fdo/register [post]
func (handler *Handler) fdoRegisterDevice(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
// Post a voucher
ov, filename, err := request.RetrieveMultiPartFormFile(r, "voucher")
if err != nil {
logrus.WithField("filename", filename).WithError(err).Info("fdoRegisterDevice: readVoucher()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: read Voucher()", Err: err}
}
fdoClient, err := handler.newFDOClient()
if err != nil {
logrus.WithError(err).Info("fdoRegisterDevice: newFDOClient()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: newFDOClient()", Err: err}
}
guid, err := fdoClient.PostVoucher(ov)
if err != nil {
logrus.WithError(err).Info("fdoRegisterDevice: PostVoucher()")
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "fdoRegisterDevice: PostVoucher()", Err: err}
}
return response.JSON(w, registerDeviceResponse{guid})
}

View File

@@ -0,0 +1,73 @@
package openamt
import (
"errors"
"fmt"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/portainer/portainer/api/internal/endpointutils"
)
// @id openAMTActivate
// @summary Activate OpenAMT device and associate to agent endpoint
// @description Activate OpenAMT device and associate to agent endpoint
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/activate [post]
func (handler *Handler) openAMTActivate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if !endpointutils.IsAgentEndpoint(endpoint) {
errMsg := fmt.Sprintf("%s is not an agent environment", endpoint.Name)
return &httperror.HandlerError{http.StatusBadRequest, errMsg, errors.New(errMsg)}
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
err = handler.activateDevice(endpoint, *settings)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to activate device", err}
}
hostInfo, _, err := handler.getEndpointAMTInfo(endpoint)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve AMT information", Err: err}
}
if hostInfo.ControlModeRaw < 1 {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to activate device", Err: errors.New("failed to activate device")}
}
if hostInfo.UUID == "" {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve device UUID", Err: errors.New("unable to retrieve device UUID")}
}
endpoint.AMTDeviceGUID = hostInfo.UUID
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err}
}
return response.Empty(w)
}

View File

@@ -16,23 +16,19 @@ import (
portainer "github.com/portainer/portainer/api"
)
type openAMTConfigureDefaultPayload struct {
EnableOpenAMT bool
MPSServer string
MPSUser string
MPSPassword string
CertFileText string
CertPassword string
DomainName string
UseWirelessConfig bool
WifiAuthenticationMethod string
WifiEncryptionMethod string
WifiSSID string
WifiPskPass string
type openAMTConfigurePayload struct {
Enabled bool
MPSServer string
MPSUser string
MPSPassword string
DomainName string
CertFileName string
CertFileContent string
CertFilePassword string
}
func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error {
if payload.EnableOpenAMT {
func (payload *openAMTConfigurePayload) Validate(r *http.Request) error {
if payload.Enabled {
if payload.MPSServer == "" {
return errors.New("MPS Server must be provided")
}
@@ -45,32 +41,21 @@ func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error {
if payload.DomainName == "" {
return errors.New("domain name must be provided")
}
if payload.CertFileText == "" {
if payload.CertFileContent == "" {
return errors.New("certificate file must be provided")
}
if payload.CertPassword == "" {
return errors.New("certificate password must be provided")
if payload.CertFileName == "" {
return errors.New("certificate file name must be provided")
}
if payload.UseWirelessConfig {
if payload.WifiAuthenticationMethod == "" {
return errors.New("wireless authentication method must be provided")
}
if payload.WifiEncryptionMethod == "" {
return errors.New("wireless encryption method must be provided")
}
if payload.WifiSSID == "" {
return errors.New("wireless config SSID must be provided")
}
if payload.WifiPskPass == "" {
return errors.New("wireless config PSK passphrase must be provided")
}
if payload.CertFilePassword == "" {
return errors.New("certificate password must be provided")
}
}
return nil
}
// @id OpenAMTConfigureDefault
// @id OpenAMTConfigure
// @summary Enable Portainer's OpenAMT capabilities
// @description Enable Portainer's OpenAMT capabilities
// @description **Access policy**: administrator
@@ -78,22 +63,22 @@ func (payload *openAMTConfigureDefaultPayload) Validate(r *http.Request) error {
// @security jwt
// @accept json
// @produce json
// @param body body openAMTConfigureDefaultPayload true "OpenAMT Settings"
// @param body body openAMTConfigurePayload true "OpenAMT Settings"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt [post]
func (handler *Handler) openAMTConfigureDefault(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload openAMTConfigureDefaultPayload
func (handler *Handler) openAMTConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload openAMTConfigurePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
logrus.WithError(err).Error("Invalid request payload")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
if payload.EnableOpenAMT {
certificateErr := validateCertificate(payload.CertFileText, payload.CertPassword)
if payload.Enabled {
certificateErr := validateCertificate(payload.CertFileContent, payload.CertFilePassword)
if certificateErr != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error validating certificate", Err: certificateErr}
}
@@ -143,31 +128,19 @@ func isValidIssuer(issuer string) bool {
strings.Contains(formattedIssuer, "godaddy")
}
func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigureDefaultPayload) error {
func (handler *Handler) enableOpenAMT(configurationPayload openAMTConfigurePayload) error {
configuration := portainer.OpenAMTConfiguration{
Enabled: true,
MPSServer: configurationPayload.MPSServer,
Credentials: portainer.MPSCredentials{
MPSUser: configurationPayload.MPSUser,
MPSPassword: configurationPayload.MPSPassword,
},
DomainConfiguration: portainer.DomainConfiguration{
CertFileText: configurationPayload.CertFileText,
CertPassword: configurationPayload.CertPassword,
DomainName: configurationPayload.DomainName,
},
Enabled: true,
MPSServer: configurationPayload.MPSServer,
MPSUser: configurationPayload.MPSUser,
MPSPassword: configurationPayload.MPSPassword,
CertFileContent: configurationPayload.CertFileContent,
CertFileName: configurationPayload.CertFileName,
CertFilePassword: configurationPayload.CertFilePassword,
DomainName: configurationPayload.DomainName,
}
if configurationPayload.UseWirelessConfig {
configuration.WirelessConfiguration = &portainer.WirelessConfiguration{
AuthenticationMethod: configurationPayload.WifiAuthenticationMethod,
EncryptionMethod: configurationPayload.WifiEncryptionMethod,
SSID: configurationPayload.WifiSSID,
PskPass: configurationPayload.WifiPskPass,
}
}
err := handler.OpenAMTService.ConfigureDefault(configuration)
err := handler.OpenAMTService.Configure(configuration)
if err != nil {
logrus.WithError(err).Error("error configuring OpenAMT server")
return err
@@ -189,7 +162,7 @@ func (handler *Handler) saveConfiguration(configuration portainer.OpenAMTConfigu
return err
}
configuration.Credentials.MPSToken = ""
configuration.MPSToken = ""
settings.OpenAMTConfiguration = configuration
err = handler.DataStore.Settings().UpdateSettings(settings)

View File

@@ -0,0 +1,178 @@
package openamt
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/sirupsen/logrus"
)
// @id OpenAMTDevices
// @summary Fetch OpenAMT managed devices information for endpoint
// @description Fetch OpenAMT managed devices information for endpoint
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices [get]
func (handler *Handler) openAMTDevices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err}
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
}
if endpoint.AMTDeviceGUID == "" {
return response.JSON(w, []portainer.OpenAMTDeviceInformation{})
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
device, err := handler.OpenAMTService.DeviceInformation(settings.OpenAMTConfiguration, endpoint.AMTDeviceGUID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve device information", err}
}
devices := []portainer.OpenAMTDeviceInformation{
*device,
}
return response.JSON(w, devices)
}
type deviceActionPayload struct {
Action string
}
func (payload *deviceActionPayload) Validate(r *http.Request) error {
if payload.Action == "" {
return errors.New("device action must be provided")
}
return nil
}
// @id DeviceAction
// @summary Execute out of band action on an AMT managed device
// @description Execute out of band action on an AMT managed device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @param body body deviceActionPayload true "Device Action"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices/{deviceId}/action [post]
func (handler *Handler) deviceAction(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
deviceID, err := request.RetrieveRouteVariableValue(r, "deviceId")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid device identifier route variable", err}
}
var payload deviceActionPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
logrus.WithError(err).Error("Invalid request payload")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
err = handler.OpenAMTService.ExecuteDeviceAction(settings.OpenAMTConfiguration, deviceID, payload.Action)
if err != nil {
logrus.WithError(err).Error("Error executing device action")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error executing device action", Err: err}
}
return response.Empty(w)
}
type deviceFeaturesPayload struct {
Features portainer.OpenAMTDeviceEnabledFeatures
}
func (payload *deviceFeaturesPayload) Validate(r *http.Request) error {
if payload.Features.UserConsent == "" {
return errors.New("device user consent status must be provided")
}
return nil
}
type AuthorizationResponse struct {
Server string
Token string
}
// @id DeviceFeatures
// @summary Enable features on an AMT managed device
// @description Enable features on an AMT managed device
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @accept json
// @produce json
// @param body body deviceFeaturesPayload true "Device Features"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/devices_features/{deviceId} [post]
func (handler *Handler) deviceFeatures(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
deviceID, err := request.RetrieveRouteVariableValue(r, "deviceId")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid device identifier route variable", err}
}
var payload deviceFeaturesPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
logrus.WithError(err).Error("Invalid request payload")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
_, err = handler.OpenAMTService.DeviceInformation(settings.OpenAMTConfiguration, deviceID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve device information", err}
}
token, err := handler.OpenAMTService.EnableDeviceFeatures(settings.OpenAMTConfiguration, deviceID, payload.Features)
if err != nil {
logrus.WithError(err).Error("Error executing device action")
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Error executing device action", Err: err}
}
authorizationResponse := AuthorizationResponse{
Server: settings.OpenAMTConfiguration.MPSServer,
Token: token,
}
return response.JSON(w, authorizationResponse)
}

View File

@@ -0,0 +1,278 @@
package openamt
import (
"context"
"encoding/json"
"fmt"
"github.com/portainer/portainer/api/hostmanagement/openamt"
"io/ioutil"
"log"
"net/http"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/dataservices/errors"
"github.com/sirupsen/logrus"
)
type HostInfo struct {
EndpointID portainer.EndpointID `json:"EndpointID"`
RawOutput string `json:"RawOutput"`
AMT string `json:"AMT"`
UUID string `json:"UUID"`
DNSSuffix string `json:"DNS Suffix"`
BuildNumber string `json:"Build Number"`
ControlMode string `json:"Control Mode"`
ControlModeRaw int `json:"Control Mode (Raw)"`
}
const (
// TODO: this should get extracted to some configurable - don't assume Docker Hub is everyone's global namespace, or that they're allowed to pull images from the internet
rpcGoImageName = "ptrrd/openamt:rpc-go-json"
rpcGoContainerName = "openamt-rpc-go"
dockerClientTimeout = 5 * time.Minute
)
// @id OpenAMTHostInfo
// @summary Request OpenAMT info from a node
// @description Request OpenAMT info from a node
// @description **Access policy**: administrator
// @tags intel
// @security jwt
// @produce json
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
// @failure 500 "Server error"
// @router /open_amt/{id}/info [get]
func (handler *Handler) openAMTHostInfo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err}
}
logrus.WithField("endpointID", endpointID).Info("OpenAMTHostInfo")
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
}
amtInfo, output, err := handler.getEndpointAMTInfo(endpoint)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: output, Err: err}
}
return response.JSON(w, amtInfo)
}
func (handler *Handler) getEndpointAMTInfo(endpoint *portainer.Endpoint) (*HostInfo, string, error) {
ctx := context.TODO()
// pull the image so we can check if there's a new one
// TODO: these should be able to be over-ridden (don't hardcode the assumption that secure users can access Docker Hub, or that its even the orchestrator's "global namespace")
cmdLine := []string{"amtinfo", "--json"}
output, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
if err != nil {
return nil, output, err
}
amtInfo := HostInfo{}
_ = json.Unmarshal([]byte(output), &amtInfo)
amtInfo.EndpointID = endpoint.ID
amtInfo.RawOutput = output
return &amtInfo, "", nil
}
func (handler *Handler) PullAndRunContainer(ctx context.Context, endpoint *portainer.Endpoint, imageName, containerName string, cmdLine []string) (output string, err error) {
// TODO: this should not be Docker specific
// TODO: extract from this Handler into something global.
// TODO: start
// docker run --rm -it --privileged ptrrd/openamt:rpc-go amtinfo
// on the Docker standalone node (one per env :)
// and later, on the specified node in the swarm, or kube.
nodeName := ""
timeout := dockerClientTimeout
docker, err := handler.DockerClientFactory.CreateClient(endpoint, nodeName, &timeout)
if err != nil {
return "Unable to create Docker Client connection", err
}
defer docker.Close()
if err := pullImage(ctx, docker, imageName); err != nil {
return "Could not pull image from registry", err
}
output, err = runContainer(ctx, docker, imageName, containerName, cmdLine)
if err != nil {
return "Could not run container", err
}
return output, nil
}
// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
// TODO: the idea being that if we have an internal struct of a parsed compose file, we can also populate that struct programmatically, and run it to get the result I'm getting here.
// TODO: likely an upgrade and abstraction of DeployComposeStack/DeploySwarmStack/DeployKubernetesStack
// pullImage will pull the image to the specified environment
// TODO: add k8s implementation
// TODO: work out registry auth
func pullImage(ctx context.Context, docker *client.Client, imageName string) error {
out, err := docker.ImagePull(ctx, imageName, types.ImagePullOptions{})
if err != nil {
logrus.WithError(err).WithField("imageName", imageName).Error("Could not pull image from registry")
return err
}
defer out.Close()
outputBytes, err := ioutil.ReadAll(out)
if err != nil {
logrus.WithError(err).WithField("imageName", imageName).Error("Could not read image pull output")
return err
}
log.Printf("%s imaged pulled with output:\n%s", imageName, string(outputBytes))
return nil
}
// TODO: ideally, pullImage and runContainer will become a simple version of the use compose abstraction that can be called from withing Portainer.
// runContainer should be used to run a short command that returns information to stdout
// TODO: add k8s support
func runContainer(ctx context.Context, docker *client.Client, imageName, containerName string, cmdLine []string) (output string, err error) {
opts := types.ContainerListOptions{All: true}
opts.Filters = filters.NewArgs()
opts.Filters.Add("name", containerName)
existingContainers, err := docker.ContainerList(ctx, opts)
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("listing existing container")
return "", err
}
if len(existingContainers) > 0 {
err = docker.ContainerRemove(ctx, existingContainers[0].ID, types.ContainerRemoveOptions{Force: true})
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("removing existing container")
return "", err
}
}
created, err := docker.ContainerCreate(
ctx,
&container.Config{
Image: imageName,
Cmd: cmdLine,
Env: []string{},
Tty: true,
OpenStdin: true,
AttachStdout: true,
AttachStderr: true,
},
&container.HostConfig{
Privileged: true,
},
&network.NetworkingConfig{},
nil,
containerName,
)
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("creating container")
return "", err
}
err = docker.ContainerStart(ctx, created.ID, types.ContainerStartOptions{})
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("starting container")
return "", err
}
log.Printf("%s container created and started\n", containerName)
statusCh, errCh := docker.ContainerWait(ctx, created.ID, container.WaitConditionNotRunning)
var statusCode int64
select {
case err := <-errCh:
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("starting container")
return "", err
}
case status := <-statusCh:
statusCode = status.StatusCode
}
logrus.WithField("status", statusCode).Debug("container wait status")
out, err := docker.ContainerLogs(ctx, created.ID, types.ContainerLogsOptions{ShowStdout: true})
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("getting container log")
return "", err
}
err = docker.ContainerRemove(ctx, created.ID, types.ContainerRemoveOptions{})
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("removing container")
return "", err
}
outputBytes, err := ioutil.ReadAll(out)
if err != nil {
logrus.WithError(err).WithField("imagename", imageName).WithField("containername", containerName).Error("read container output")
return "", err
}
log.Printf("%s container finished with output:\n%s", containerName, string(outputBytes))
return string(outputBytes), nil
}
func (handler *Handler) activateDevice(endpoint *portainer.Endpoint, settings portainer.Settings) error {
ctx := context.TODO()
config := settings.OpenAMTConfiguration
cmdLine := []string{
"activate",
"-n",
"-v",
"-u", fmt.Sprintf("wss://%s/activate", config.MPSServer),
"-profile", openamt.DefaultProfileName,
"-d", config.DomainName,
"-password", config.MPSPassword,
}
_, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
if err != nil {
return err
}
return nil
}
func (handler *Handler) deactivateDevice(endpoint *portainer.Endpoint, settings portainer.Settings) error {
ctx := context.TODO()
config := settings.OpenAMTConfiguration
cmdLine := []string{
"deactivate",
"-n",
"-v",
"-u", fmt.Sprintf("wss://%s/activate", config.MPSServer),
"-password", config.MPSPassword,
}
_, err := handler.PullAndRunContainer(ctx, endpoint, rpcGoImageName, rpcGoContainerName, cmdLine)
if err != nil {
return err
}
return nil
}

View File

@@ -8,27 +8,30 @@ import (
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker"
"github.com/portainer/portainer/api/http/security"
)
// Handler is the HTTP handler used to handle OpenAMT operations.
type Handler struct {
*mux.Router
OpenAMTService portainer.OpenAMTService
DataStore dataservices.DataStore
OpenAMTService portainer.OpenAMTService
DataStore dataservices.DataStore
DockerClientFactory *docker.ClientFactory
}
// NewHandler returns a new Handler
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore) (*Handler, error) {
if !dataStore.Settings().IsFeatureFlagEnabled(portainer.FeatOpenAMT) {
return nil, nil
}
func NewHandler(bouncer *security.RequestBouncer) (*Handler, error) {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/open_amt", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigureDefault))).Methods(http.MethodPost)
h.Handle("/open_amt/configure", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigure))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/info", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTHostInfo))).Methods(http.MethodGet)
h.Handle("/open_amt/{id}/activate", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTActivate))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/devices", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTDevices))).Methods(http.MethodGet)
h.Handle("/open_amt/{id}/devices/{deviceId}/action", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceAction))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/devices/{deviceId}/features", bouncer.AdminAccess(httperror.LoggerHandler(h.deviceFeatures))).Methods(http.MethodPost)
return h, nil
}

View File

@@ -154,7 +154,7 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
return false, err
}
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "")
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil)
if err != nil {
return false, err
}

View File

@@ -68,7 +68,7 @@ func (handler *Handler) executeServiceWebhook(
registryID portainer.RegistryID,
imageTag string,
) *httperror.HandlerError {
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "")
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "", nil)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err}
}

View File

@@ -122,7 +122,7 @@ func (transport *Transport) createPrivateResourceControl(resourceIdentifier stri
}
func (transport *Transport) getInheritedResourceControlFromServiceOrStack(resourceIdentifier, nodeName string, resourceType portainer.ResourceControlType, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName)
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodeName, nil)
if err != nil {
return nil, err
}

View File

@@ -133,7 +133,7 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
if volumeID != "" {
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader)
cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, agentTargetHeader, nil)
if err != nil {
return nil, err
}
@@ -219,7 +219,7 @@ func (transport *Transport) getDockerID() (string, error) {
}
}
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "")
client, err := transport.dockerClientFactory.CreateClient(transport.endpoint, "", nil)
if err != nil {
return "", err
}

View File

@@ -30,6 +30,7 @@ import (
"github.com/portainer/portainer/api/http/handler/endpoints"
"github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/helm"
"github.com/portainer/portainer/api/http/handler/hostmanagement/fdo"
"github.com/portainer/portainer/api/http/handler/hostmanagement/openamt"
kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes"
"github.com/portainer/portainer/api/http/handler/ldap"
@@ -209,15 +210,18 @@ func (server *Server) Start() error {
var sslHandler = sslhandler.NewHandler(requestBouncer)
sslHandler.SSLService = server.SSLService
openAMTHandler, err := openamt.NewHandler(requestBouncer, server.DataStore)
openAMTHandler, err := openamt.NewHandler(requestBouncer)
if err != nil {
return err
}
if openAMTHandler != nil {
openAMTHandler.OpenAMTService = server.OpenAMTService
openAMTHandler.DataStore = server.DataStore
openAMTHandler.DockerClientFactory = server.DockerClientFactory
}
fdoHandler := fdo.NewHandler(requestBouncer, server.DataStore)
var stackHandler = stacks.NewHandler(requestBouncer)
stackHandler.DataStore = server.DataStore
stackHandler.DockerClientFactory = server.DockerClientFactory
@@ -284,6 +288,7 @@ func (server *Server) Start() error {
KubernetesHandler: kubernetesHandler,
MOTDHandler: motdHandler,
OpenAMTHandler: openAMTHandler,
FDOHandler: fdoHandler,
RegistryHandler: registryHandler,
ResourceControlHandler: resourceControlHandler,
SettingsHandler: settingsHandler,

View File

@@ -46,6 +46,23 @@ func Test_IsKubernetesEndpoint(t *testing.T) {
}
}
func Test_IsAgentEndpoint(t *testing.T) {
tests := []isEndpointTypeTest{
{endpointType: portainer.DockerEnvironment, expected: false},
{endpointType: portainer.AgentOnDockerEnvironment, expected: true},
{endpointType: portainer.AzureEnvironment, expected: false},
{endpointType: portainer.EdgeAgentOnDockerEnvironment, expected: true},
{endpointType: portainer.KubernetesLocalEnvironment, expected: false},
{endpointType: portainer.AgentOnKubernetesEnvironment, expected: true},
{endpointType: portainer.EdgeAgentOnKubernetesEnvironment, expected: true},
}
for _, test := range tests {
ans := IsAgentEndpoint(&portainer.Endpoint{Type: test.endpointType})
assert.Equal(t, test.expected, ans)
}
}
func Test_FilterByExcludeIDs(t *testing.T) {
tests := []struct {
name string
@@ -101,4 +118,4 @@ func Test_FilterByExcludeIDs(t *testing.T) {
tt.asserts(t, output)
})
}
}
}

View File

@@ -30,6 +30,14 @@ func IsEdgeEndpoint(endpoint *portainer.Endpoint) bool {
return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
}
// IsAgentEndpoint returns true if this is an Agent endpoint
func IsAgentEndpoint(endpoint *portainer.Endpoint) bool {
return endpoint.Type == portainer.AgentOnDockerEnvironment ||
endpoint.Type == portainer.EdgeAgentOnDockerEnvironment ||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
}
// FilterByExcludeIDs receives an environment(endpoint) array and returns a filtered array using an excludeIds param
func FilterByExcludeIDs(endpoints []portainer.Endpoint, excludeIds []portainer.EndpointID) []portainer.Endpoint {
if len(excludeIds) == 0 {

View File

@@ -41,30 +41,44 @@ type (
// OpenAMTConfiguration represents the credentials and configurations used to connect to an OpenAMT MPS server
OpenAMTConfiguration struct {
Enabled bool `json:"Enabled"`
MPSServer string `json:"MPSServer"`
Credentials MPSCredentials `json:"Credentials"`
DomainConfiguration DomainConfiguration `json:"DomainConfiguration"`
WirelessConfiguration *WirelessConfiguration `json:"WirelessConfiguration"`
Enabled bool `json:"enabled"`
MPSServer string `json:"mpsServer"`
MPSUser string `json:"mpsUser"`
MPSPassword string `json:"mpsPassword"`
MPSToken string `json:"mpsToken"` // retrieved from API
CertFileName string `json:"certFileName"`
CertFileContent string `json:"certFileContent"`
CertFilePassword string `json:"certFilePassword"`
DomainName string `json:"domainName"`
}
MPSCredentials struct {
MPSUser string `json:"MPSUser"`
MPSPassword string `json:"MPSPassword"`
MPSToken string `json:"MPSToken"` // retrieved from API
// OpenAMTDeviceInformation represents an AMT managed device information
OpenAMTDeviceInformation struct {
GUID string `json:"guid"`
HostName string `json:"hostname"`
ConnectionStatus bool `json:"connectionStatus"`
PowerState PowerState `json:"powerState"`
EnabledFeatures *OpenAMTDeviceEnabledFeatures `json:"features"`
}
DomainConfiguration struct {
CertFileText string `json:"CertFileText"`
CertPassword string `json:"CertPassword"`
DomainName string `json:"DomainName"`
// OpenAMTDeviceEnabledFeatures represents an AMT managed device features information
OpenAMTDeviceEnabledFeatures struct {
Redirection bool `json:"redirection"`
KVM bool `json:"KVM"`
SOL bool `json:"SOL"`
IDER bool `json:"IDER"`
UserConsent string `json:"userConsent"`
}
WirelessConfiguration struct {
AuthenticationMethod string `json:"AuthenticationMethod"`
EncryptionMethod string `json:"EncryptionMethod"`
SSID string `json:"SSID"`
PskPass string `json:"PskPass"`
// PowerState represents an AMT managed device power state
PowerState int
FDOConfiguration struct {
Enabled bool `json:"enabled"`
OwnerURL string `json:"ownerURL"`
OwnerUsername string `json:"ownerUsername"`
OwnerPassword string `json:"ownerPassword"`
ProfilesURL string `json:"profilesURL"`
}
// CLIFlags represents the available flags on the CLI
@@ -294,8 +308,12 @@ type (
ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion" example:"3.8"`
// Environment(Endpoint) specific security settings
SecuritySettings EndpointSecuritySettings
// The identifier of the AMT Device associated with this environment(endpoint)
AMTDeviceGUID string `json:"AMTDeviceGUID,omitempty" example:"4c4c4544-004b-3910-8037-b6c04f504633"`
// LastCheckInDate mark last check-in date on checkin
LastCheckInDate int64
// IsEdgeDevice marks if the environment was created as an EdgeDevice
IsEdgeDevice bool
// Deprecated fields
// Deprecated in DBVersion == 4
@@ -764,7 +782,8 @@ type (
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
LDAPSettings LDAPSettings `json:"LDAPSettings" example:""`
OAuthSettings OAuthSettings `json:"OAuthSettings" example:""`
OpenAMTConfiguration OpenAMTConfiguration `json:"OpenAMTConfiguration" example:""`
OpenAMTConfiguration OpenAMTConfiguration `json:"openAMTConfiguration" example:""`
FDOConfiguration FDOConfiguration `json:"fdoConfiguration" example:""`
FeatureFlagSettings map[Feature]bool `json:"FeatureFlagSettings" example:""`
// The interval in which environment(endpoint) snapshots are created
SnapshotInterval string `json:"SnapshotInterval" example:"5m"`
@@ -1218,7 +1237,10 @@ type (
// OpenAMTService represents a service for managing OpenAMT
OpenAMTService interface {
ConfigureDefault(configuration OpenAMTConfiguration) error
Configure(configuration OpenAMTConfiguration) error
DeviceInformation(configuration OpenAMTConfiguration, deviceGUID string) (*OpenAMTDeviceInformation, error)
EnableDeviceFeatures(configuration OpenAMTConfiguration, deviceGUID string, features OpenAMTDeviceEnabledFeatures) (string, error)
ExecuteDeviceAction(configuration OpenAMTConfiguration, deviceGUID string, action string) error
}
// KubeClient represents a service used to query a Kubernetes environment(endpoint)
@@ -1348,17 +1370,8 @@ const (
WebSocketKeepAlive = 1 * time.Hour
)
// Supported feature flags
const (
FeatOpenAMT Feature = "open-amt"
FeatFDO Feature = "fdo"
)
// List of supported features
var SupportedFeatureFlags = []Feature{
FeatOpenAMT,
FeatFDO,
}
var SupportedFeatureFlags = []Feature{}
const (
_ AuthenticationMethod = iota

View File

@@ -893,6 +893,15 @@ json-tree .branch-preview {
white-space: normal;
}
.icon-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.space-x-1 {
margin-left: 0.25rem;
}
.form-check.radio {
margin-left: 15px;
}

View File

@@ -264,6 +264,7 @@ html {
--bg-multiselect-helpercontainer: var(--white-color);
--text-input-textarea: var(--white-color);
--bg-service-datatable-thead: var(--grey-23);
--bg-inner-datatable-thead: var(--grey-23);
--bg-service-datatable-tbody: var(--grey-24);
}
@@ -435,6 +436,7 @@ html {
--bg-multiselect-helpercontainer: var(--grey-1);
--text-input-textarea: var(--grey-1);
--bg-service-datatable-thead: var(--grey-1);
--bg-inner-datatable-thead: var(--grey-1);
--bg-service-datatable-tbody: var(--grey-1);
}
@@ -472,6 +474,7 @@ html {
--bg-input-sm-color: var(--black-color);
--bg-item-highlighted-color: var(--black-color);
--bg-service-datatable-thead: var(--black-color);
--bg-inner-datatable-thead: var(--black-color);
--bg-service-datatable-tbody: var(--black-color);
--bg-pagination-color: var(--grey-3);
--bg-pagination-span-color: var(--grey-3);

View File

@@ -141,7 +141,7 @@ export function ContainersDatatable({
<TableContainer>
<TableTitle icon="fa-cubes" label="Containers">
<TableTitleActions>
<ColumnVisibilityMenu
<ColumnVisibilityMenu<DockerContainer>
columns={columnsToHide}
onChange={handleChangeColumnsVisibility}
value={settings.hiddenColumns}

View File

@@ -1,8 +1,9 @@
import angular from 'angular';
import edgeStackModule from './views/edge-stacks';
import edgeDevicesModule from './devices';
angular.module('portainer.edge', [edgeStackModule]).config(function config($stateRegistryProvider) {
angular.module('portainer.edge', [edgeStackModule, edgeDevicesModule]).config(function config($stateRegistryProvider) {
const edge = {
name: 'edge',
url: '/edge',
@@ -106,6 +107,16 @@ angular.module('portainer.edge', [edgeStackModule]).config(function config($stat
},
};
const edgeDevices = {
name: 'edge.devices',
url: '/devices',
views: {
'content@': {
component: 'edgeDevicesView',
},
},
};
$stateRegistryProvider.register(edge);
$stateRegistryProvider.register(groups);
@@ -119,4 +130,6 @@ angular.module('portainer.edge', [edgeStackModule]).config(function config($stat
$stateRegistryProvider.register(edgeJobs);
$stateRegistryProvider.register(edgeJob);
$stateRegistryProvider.register(edgeJobCreation);
$stateRegistryProvider.register(edgeDevices);
});

View File

@@ -2,7 +2,7 @@
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
@@ -62,9 +62,7 @@
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<span style="margin-right: 5px"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>

View File

@@ -1,7 +1,5 @@
<form class="form-horizontal" name="edgeJobForm">
<div class="col-sm-12 form-section-title">
Edge job configuration
</div>
<div class="col-sm-12 form-section-title"> Edge job configuration </div>
<!-- name-input -->
<div class="form-group">
<label for="edgejob_name" class="col-sm-1 control-label text-left">Name</label>
@@ -30,17 +28,15 @@
<!-- !name-input -->
<!-- cron-input -->
<!-- edge-job-method-select -->
<div class="col-sm-12 form-section-title">
Edge job configuration
</div>
<div class="col-sm-12 form-section-title"> Edge job configuration </div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0;">
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="config_basic" ng-model="$ctrl.formValues.cronMethod" value="basic" />
<label for="config_basic">
<div class="boxselector_header">
<i class="fa fa-calendar-alt" aria-hidden="true" style="margin-right: 2px;"></i>
<i class="fa fa-calendar-alt" aria-hidden="true" style="margin-right: 2px"></i>
Basic configuration
</div>
<p>Select date from calendar</p>
@@ -50,7 +46,7 @@
<input type="radio" id="config_advanced" ng-model="$ctrl.formValues.cronMethod" value="advanced" />
<label for="config_advanced">
<div class="boxselector_header">
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px"></i>
Advanced configuration
</div>
<p>Write your own cron rule</p>
@@ -64,7 +60,7 @@
<div class="form-group">
<label for="recurring" class="col-sm-2 control-label text-left">Recurring Edge job</label>
<div class="col-sm-10">
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" name="recurring" ng-model="$ctrl.model.Recurring" /><i></i> </label>
<label class="switch" style="margin-left: 20px"> <input type="checkbox" name="recurring" ng-model="$ctrl.model.Recurring" /><i></i> </label>
</div>
</div>
<!-- not-recurring -->
@@ -74,9 +70,7 @@
<div class="col-sm-10">
<input class="form-control" moment-picker ng-model="$ctrl.formValues.datetime" format="YYYY-MM-DD HH:mm" />
</div>
<div class="col-sm-12 small text-muted" style="margin-top: 10px;">
Time should be set according to the chosen environments' timezone.
</div>
<div class="col-sm-12 small text-muted" style="margin-top: 10px"> Time should be set according to the chosen environments' timezone. </div>
<div ng-show="edgeJobForm.datepicker.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="edgeJobForm.datepicker.$error">
@@ -129,9 +123,7 @@
ng-pattern="$ctrl.cronRegex"
/>
</div>
<div class="col-sm-12 small text-muted" style="margin-top: 10px;">
Time should be set according to the chosen environments' timezone.
</div>
<div class="col-sm-12 small text-muted" style="margin-top: 10px"> Time should be set according to the chosen environments' timezone. </div>
</div>
<div class="form-group" ng-show="edgeJobForm.edgejob_cron.$invalid && edgeJobForm.edgejob_cron.$dirty">
<div class="col-sm-12 small text-warning">
@@ -146,17 +138,15 @@
<!-- execution-method -->
<div ng-if="!$ctrl.model.Id">
<div class="col-sm-12 form-section-title">
Job content
</div>
<div class="col-sm-12 form-section-title"> Job content </div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0;">
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="$ctrl.formValues.method" value="editor" />
<label for="method_editor">
<div class="boxselector_header">
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px"></i>
Web editor
</div>
<p>Use our Web editor</p>
@@ -166,7 +156,7 @@
<input type="radio" id="method_upload" ng-model="$ctrl.formValues.method" value="upload" />
<label for="method_upload">
<div class="boxselector_header">
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px"></i>
Upload
</div>
<p>Upload from your computer</p>
@@ -178,9 +168,7 @@
<!-- !execution-method -->
<!-- web-editor -->
<div ng-show="$ctrl.formValues.method === 'editor'">
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="col-sm-12 form-section-title"> Web editor </div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
@@ -195,18 +183,14 @@
<!-- !web-editor -->
<!-- upload -->
<div ng-show="$ctrl.formValues.method === 'upload'">
<div class="col-sm-12 form-section-title">
Upload
</div>
<div class="col-sm-12 form-section-title"> Upload </div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can upload a script file from your computer.
</span>
<span class="col-sm-12 text-muted small"> You can upload a script file from your computer. </span>
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.model.File">Select file</button>
<span style="margin-left: 5px;">
<span style="margin-left: 5px">
{{ $ctrl.model.File.name }}
<i class="fa fa-times red-icon" ng-if="!$ctrl.model.File" aria-hidden="true"></i>
</span>
@@ -214,9 +198,7 @@
</div>
</div>
<!-- !upload -->
<div class="col-sm-12 form-section-title">
Target environments
</div>
<div class="col-sm-12 form-section-title"> Target environments </div>
<!-- node-selection -->
<associated-endpoints-selector
endpoint-ids="$ctrl.model.Endpoints"
@@ -228,9 +210,7 @@
></associated-endpoints-selector>
<!-- !node-selection -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12">
<button
@@ -247,7 +227,7 @@
<span ng-hide="$ctrl.actionInProgress">{{ $ctrl.formActionLabel }}</span>
<span ng-show="$ctrl.actionInProgress">In progress...</span>
</button>
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px">
{{ $ctrl.state.formValidationError }}
</span>
</div>

View File

@@ -31,7 +31,8 @@ export class EdgeJobFormController {
};
// see https://regexr.com/573i2
this.cronRegex = /(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ){4,6}((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*))/;
this.cronRegex =
/(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ){4,6}((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*))/;
this.action = this.action.bind(this);
this.editorUpdate = this.editorUpdate.bind(this);

View File

@@ -3,7 +3,7 @@
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i>
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i>
{{ $ctrl.titleText }}
</div>
</div>
@@ -22,9 +22,7 @@
Environment
</a>
</th>
<th>
Actions
</th>
<th> Actions </th>
</tr>
</thead>
<tbody>
@@ -35,18 +33,10 @@
{{ item.Endpoint.Name }}
</td>
<td>
<button ng-if="item.LogsStatus === 0 || item.LogsStatus === 1" class="btn btn-sm btn-primary" ng-click="$ctrl.collectLogs(item.EndpointId)">
Retrieve logs
</button>
<button ng-if="item.LogsStatus === 3" class="btn btn-sm btn-primary" ng-click="$ctrl.onDownloadLogsClick(item.EndpointId)">
Download logs
</button>
<button ng-if="item.LogsStatus === 3" class="btn btn-sm btn-primary" ng-click="$ctrl.onClearLogsClick(item.EndpointId)">
Clear logs
</button>
<span ng-if="item.LogsStatus === 2">
Logs marked for collection, please wait until the logs are available.
</span>
<button ng-if="item.LogsStatus === 0 || item.LogsStatus === 1" class="btn btn-sm btn-primary" ng-click="$ctrl.collectLogs(item.EndpointId)"> Retrieve logs </button>
<button ng-if="item.LogsStatus === 3" class="btn btn-sm btn-primary" ng-click="$ctrl.onDownloadLogsClick(item.EndpointId)"> Download logs </button>
<button ng-if="item.LogsStatus === 3" class="btn btn-sm btn-primary" ng-click="$ctrl.onClearLogsClick(item.EndpointId)"> Clear logs </button>
<span ng-if="item.LogsStatus === 2"> Logs marked for collection, please wait until the logs are available. </span>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
@@ -62,9 +52,7 @@
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<span style="margin-right: 5px"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>

View File

@@ -2,7 +2,7 @@
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
@@ -76,9 +76,7 @@
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<span style="margin-right: 5px"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>

View File

@@ -1,4 +1,2 @@
<div class="col-sm-12 form-section-title">
Deployment type
</div>
<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>

View File

@@ -2,7 +2,7 @@
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
@@ -67,9 +67,7 @@
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<span style="margin-right: 5px"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>

View File

@@ -3,7 +3,7 @@
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i>
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i>
Edge Stacks
</div>
<div class="settings" data-cy="edgeStack-stackTableSettings">
@@ -11,9 +11,7 @@
<span uib-dropdown-toggle> <i class="fa fa-cog" aria-hidden="true"></i> Settings </span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Table settings
</div>
<div class="menuHeader"> Table settings </div>
<div class="menuContent">
<div>
<div class="md-checkbox">
@@ -27,9 +25,7 @@
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate">
Refresh rate
</label>
<label for="settings_refresh_rate"> Refresh rate </label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
@@ -38,15 +34,13 @@
<option value="300">5min</option>
</select>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none"></i>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">
Close
</a>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;"> Close </a>
</div>
</div>
</div>
@@ -94,9 +88,7 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
Status
</th>
<th> Status </th>
<th>
<a ng-click="$ctrl.changeOrderBy('CreationDate')">
Creation Date
@@ -127,9 +119,7 @@
<td colspan="4" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0" data-cy="edgeStack-noStackRow">
<td colspan="4" class="text-center text-muted">
No stack available.
</td>
<td colspan="4" class="text-center text-muted"> No stack available. </td>
</tr>
</tbody>
</table>
@@ -139,9 +129,7 @@
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<span style="margin-right: 5px"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>

View File

@@ -1,7 +1,5 @@
<form class="form-horizontal">
<div class="col-sm-12 form-section-title">
Edge Groups
</div>
<div class="col-sm-12 form-section-title"> Edge Groups </div>
<div class="form-group">
<div class="col-sm-12">
<edge-groups-selector model="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
@@ -44,9 +42,7 @@
<editor-description>
<div>
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>
<a href="https://docs.docker.com/compose/compose-file/" target="_blank"> official documentation </a>
.
</div>
</editor-description>
@@ -69,9 +65,7 @@
</web-editor-form>
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12">
<button

View File

@@ -1,8 +1,6 @@
<form class="form-horizontal" name="EdgeGroupForm" ng-submit="$ctrl.formAction()">
<div class="form-group">
<label for="group_name" class="col-sm-3 col-lg-2 control-label text-left">
Name
</label>
<label for="group_name" class="col-sm-3 col-lg-2 control-label text-left"> Name </label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="group_name" name="group_name" ng-model="$ctrl.model.Name" required auto-focus data-cy="edgeGroupCreate-groupNameInput" />
</div>
@@ -18,16 +16,14 @@
</div>
</div>
<div class="col-sm-12 form-section-title">
Group type
</div>
<div class="col-sm-12 form-section-title"> Group type </div>
<div class="form-group col-sm-12">
<div class="boxselector_wrapper">
<div class="boxselector">
<input type="radio" id="static-group" ng-model="$ctrl.model.Dynamic" ng-value="false" ng-checked="!$ctrl.model.Dynamic" />
<label for="static-group">
<div class="boxselector_header">
<i class="fa fa-list-ol" aria-hidden="true" style="margin-right: 2px;"></i>
<i class="fa fa-list-ol" aria-hidden="true" style="margin-right: 2px"></i>
Static
</div>
<p>Manually select Edge environments</p>
@@ -37,7 +33,7 @@
<input type="radio" id="dynamic-group" ng-model="$ctrl.model.Dynamic" ng-value="true" ng-checked="$ctrl.model.Dynamic" />
<label for="dynamic-group">
<div class="boxselector_header">
<i class="fa fa-tags" aria-hidden="true" style="margin-right: 2px;"></i>
<i class="fa fa-tags" aria-hidden="true" style="margin-right: 2px"></i>
Dynamic
</div>
<p>Automatically associate environments via tags</p>
@@ -50,9 +46,7 @@
<div ng-if="!$ctrl.model.Dynamic">
<div ng-if="!$ctrl.noEndpoints">
<!-- environments -->
<div class="col-sm-12 form-section-title">
Associated environments
</div>
<div class="col-sm-12 form-section-title"> Associated environments </div>
<div class="form-group">
<associated-endpoints-selector
endpoint-ids="$ctrl.model.Endpoints"
@@ -74,16 +68,14 @@
<!-- DynamicGroup -->
<div ng-if="$ctrl.model.Dynamic">
<div class="col-sm-12 form-section-title">
Tags
</div>
<div class="col-sm-12 form-section-title"> Tags </div>
<div ng-if="$ctrl.tags.length" class="form-group col-sm-12">
<div class="boxselector_wrapper">
<div class="boxselector">
<input type="radio" id="or-selector" ng-model="$ctrl.model.PartialMatch" ng-value="true" ng-checked="$ctrl.model.PartialMatch" />
<label for="or-selector">
<div class="boxselector_header">
<i class="fa fa-tag" aria-hidden="true" style="margin-right: 2px;"></i>
<i class="fa fa-tag" aria-hidden="true" style="margin-right: 2px"></i>
Partial match
</div>
<p>Associate any environment matching at least one of the selected tags</p>
@@ -93,7 +85,7 @@
<input type="radio" id="and-selector" ng-model="$ctrl.model.PartialMatch" ng-value="false" ng-checked="!$ctrl.model.PartialMatch" />
<label for="and-selector">
<div class="boxselector_header">
<i class="fa fa-tag" aria-hidden="true" style="margin-right: 2px;"></i>
<i class="fa fa-tag" aria-hidden="true" style="margin-right: 2px"></i>
Full match
</div>
<p>Associate any environment matching all of the selected tags</p>
@@ -107,9 +99,7 @@
No tags available. Head over to the <a ui-sref="portainer.tags">Tags view</a> to add tags
</div>
</div>
<div class="col-sm-12 form-section-title">
Associated environments by tags
</div>
<div class="col-sm-12 form-section-title"> Associated environments by tags </div>
<div class="col-sm-12 form-group">
<group-association-table
loaded="$ctrl.loaded"
@@ -130,9 +120,7 @@
<!-- !DynamicGroup -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12">
<button

View File

@@ -79,9 +79,7 @@
<td colspan="4" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="4" class="text-center text-muted">
No Edge group available.
</td>
<td colspan="4" class="text-center text-muted"> No Edge group available. </td>
</tr>
</tbody>
</table>
@@ -91,9 +89,7 @@
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<span style="margin-right: 5px"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>

View File

@@ -0,0 +1,108 @@
import { useTable, usePagination } from 'react-table';
import {
Table,
TableContainer,
TableHeaderRow,
TableRow,
} from '@/portainer/components/datatables/components';
import { InnerDatatable } from '@/portainer/components/datatables/components/InnerDatatable';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
import { useAMTDevices } from '@/edge/devices/components/AMTDevicesDatatable/useAMTDevices';
import { RowProvider } from '@/edge/devices/components/AMTDevicesDatatable/columns/RowContext';
import { EnvironmentId } from '@/portainer/environments/types';
import PortainerError from '@/portainer/error';
import { useColumns } from './columns';
export interface AMTDevicesTableProps {
environmentId: EnvironmentId;
}
export function AMTDevicesDatatable({ environmentId }: AMTDevicesTableProps) {
const columns = useColumns();
const { isLoading, devices, error } = useAMTDevices(environmentId);
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } =
useTable<Device>(
{
columns,
data: devices || [],
},
usePagination
);
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return (
<InnerDatatable>
<TableContainer>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Device>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{!isLoading && devices && devices.length > 0 ? (
page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<RowProvider key={key} environmentId={environmentId}>
<TableRow<Device>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
</RowProvider>
);
})
) : (
<tr>
<td colSpan={5} className="text-center text-muted">
{userMessage(isLoading, error)}
</td>
</tr>
)}
</tbody>
</Table>
</TableContainer>
</InnerDatatable>
);
}
function userMessage(isLoading: boolean, error?: PortainerError) {
if (isLoading) {
return 'Loading...';
}
if (error) {
return error.message;
}
return 'No devices found';
}

View File

@@ -0,0 +1,43 @@
import {
createContext,
useContext,
useMemo,
useReducer,
PropsWithChildren,
} from 'react';
import { EnvironmentId } from 'Portainer/environments/types';
interface RowContextState {
environmentId: EnvironmentId;
isLoading: boolean;
toggleIsLoading(): void;
}
const RowContext = createContext<RowContextState | null>(null);
export interface RowProviderProps {
environmentId: EnvironmentId;
}
export function RowProvider({
environmentId,
children,
}: PropsWithChildren<RowProviderProps>) {
const [isLoading, toggleIsLoading] = useReducer((state) => !state, false);
const state = useMemo(
() => ({ isLoading, toggleIsLoading, environmentId }),
[isLoading, toggleIsLoading]
);
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
}
export function useRowContext() {
const context = useContext(RowContext);
if (!context) {
throw new Error('should be nested under RowProvider');
}
return context;
}

View File

@@ -0,0 +1,110 @@
import { CellProps, Column, TableInstance } from 'react-table';
import { useSref } from '@uirouter/react';
import { MenuItem, MenuLink } from '@reach/menu-button';
import { useQueryClient } from 'react-query';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
import { ActionsMenu } from '@/portainer/components/datatables/components/ActionsMenu';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { executeDeviceAction } from '@/portainer/hostmanagement/open-amt/open-amt.service';
import * as notifications from '@/portainer/services/notifications';
import { ActionsMenuTitle } from '@/portainer/components/datatables/components/ActionsMenuTitle';
import { useRowContext } from '@/edge/devices/components/AMTDevicesDatatable/columns/RowContext';
import { DeviceAction } from '@/edge/devices/types';
export const actions: Column<Device> = {
Header: 'Actions',
accessor: () => 'actions',
id: 'actions',
disableFilters: true,
canHide: true,
disableResizing: true,
width: '5px',
sortType: 'string',
Filter: () => null,
Cell: ActionsCell,
};
export function ActionsCell({
row: { original: device },
}: CellProps<TableInstance>) {
const queryClient = useQueryClient();
const { isLoading, toggleIsLoading, environmentId } = useRowContext();
const kvmLinkProps = useSref('portainer.endpoints.endpoint.kvm', {
id: environmentId,
deviceId: device.guid,
deviceName: device.hostname,
});
return (
<ActionsMenu>
<ActionsMenuTitle>AMT Functions</ActionsMenuTitle>
<MenuItem
disabled={isLoading}
onSelect={() => handleDeviceActionClick(DeviceAction.POWER_ON)}
>
Power ON
</MenuItem>
<MenuItem
disabled={isLoading}
onSelect={() => handleDeviceActionClick(DeviceAction.POWER_OFF)}
>
Power OFF
</MenuItem>
<MenuItem
disabled={isLoading}
onSelect={() => handleDeviceActionClick(DeviceAction.RESTART)}
>
Restart
</MenuItem>
<MenuLink
href={kvmLinkProps.href}
onClick={kvmLinkProps.onClick}
disabled={isLoading}
>
KVM
</MenuLink>
</ActionsMenu>
);
async function handleDeviceActionClick(action: string) {
const confirmed = await confirmAsync({
title: 'Confirm action',
message: `Are you sure you want to ${action} the device?`,
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
if (!confirmed) {
return;
}
try {
toggleIsLoading();
await executeDeviceAction(environmentId, device.guid, action);
notifications.success(
`${action} action sent successfully`,
device.hostname
);
await queryClient.invalidateQueries(['amt_devices', environmentId]);
} catch (err) {
notifications.error(
'Failure',
err as Error,
`Failed to ${action} the device`
);
} finally {
toggleIsLoading();
}
}
}

View File

@@ -0,0 +1,13 @@
import { Column } from 'react-table';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
export const hostname: Column<Device> = {
Header: 'Hostname',
accessor: (row) => row.hostname || '-',
id: 'hostname',
disableFilters: true,
canHide: true,
sortType: 'string',
Filter: () => null,
};

View File

@@ -0,0 +1,10 @@
import { useMemo } from 'react';
import { hostname } from './hostname';
import { status } from './status';
import { powerState } from './power-state';
import { actions } from './actions';
export function useColumns() {
return useMemo(() => [hostname, status, powerState, actions], []);
}

View File

@@ -0,0 +1,56 @@
import { CellProps, Column, TableInstance } from 'react-table';
import clsx from 'clsx';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
import { useRowContext } from '@/edge/devices/components/AMTDevicesDatatable/columns/RowContext';
import { PowerState, PowerStateCode } from '@/edge/devices/types';
export const powerState: Column<Device> = {
Header: 'Power State',
accessor: (row) => parsePowerState(row.powerState),
id: 'powerState',
disableFilters: true,
canHide: true,
sortType: 'string',
Cell: PowerStateCell,
Filter: () => null,
};
export function PowerStateCell({
row: { original: device },
}: CellProps<TableInstance>) {
const { isLoading } = useRowContext();
return (
<>
<span
className={clsx({
'text-success': device.powerState === PowerStateCode.ON,
})}
>
{parsePowerState(device.powerState)}
</span>
<span>{isLoading && <i className="fa fa-cog fa-spin space-left" />}</span>
</>
);
}
function parsePowerState(value: number) {
// https://app.swaggerhub.com/apis-docs/rbheopenamt/mps/1.4.0#/AMT/get_api_v1_amt_power_state__guid_
switch (value) {
case PowerStateCode.ON:
return PowerState.RUNNING;
case PowerStateCode.SLEEP_LIGHT:
case PowerStateCode.SLEEP_DEEP:
return PowerState.SLEEP;
case PowerStateCode.OFF_HARD:
case PowerStateCode.OFF_SOFT:
case PowerStateCode.OFF_HARD_GRACEFUL:
return PowerState.OFF;
case PowerStateCode.HIBERNATE:
return PowerState.HIBERNATE;
case PowerStateCode.POWER_CYCLE:
return PowerState.POWER_CYCLE;
default:
return '-';
}
}

View File

@@ -0,0 +1,24 @@
import { CellProps, Column, TableInstance } from 'react-table';
import clsx from 'clsx';
import { Device } from '@/portainer/hostmanagement/open-amt/model';
export const status: Column<Device> = {
Header: 'MPS Status',
id: 'status',
disableFilters: true,
canHide: true,
sortType: 'string',
Cell: StatusCell,
Filter: () => null,
};
export function StatusCell({
row: { original: device },
}: CellProps<TableInstance>) {
return (
<span className={clsx({ 'text-success': device.connectionStatus })}>
{device.connectionStatus ? 'Connected' : 'Disconnected'}
</span>
);
}

View File

@@ -0,0 +1,33 @@
import { useEffect } from 'react';
import { useQuery } from 'react-query';
import { getDevices } from '@/portainer/hostmanagement/open-amt/open-amt.service';
import { EnvironmentId } from '@/portainer/environments/types';
import PortainerError from '@/portainer/error';
import * as notifications from '@/portainer/services/notifications';
export function useAMTDevices(environmentId: EnvironmentId) {
const { isLoading, data, isError, error } = useQuery(
['amt_devices', environmentId],
() => getDevices(environmentId),
{
retry: false,
}
);
useEffect(() => {
if (isError) {
notifications.error(
'Failure',
error as Error,
'Failed retrieving AMT devices'
);
}
}, [isError, error]);
return {
isLoading,
devices: data,
error: isError ? (error as PortainerError) : undefined,
};
}

View File

@@ -0,0 +1,239 @@
import { Fragment, useEffect } from 'react';
import {
useTable,
useExpanded,
useSortBy,
useFilters,
useGlobalFilter,
usePagination,
} from 'react-table';
import { useRowSelectColumn } from '@lineup-lite/hooks';
import { Environment } from '@/portainer/environments/types';
import { PaginationControls } from '@/portainer/components/pagination-controls';
import {
Table,
TableActions,
TableContainer,
TableHeaderRow,
TableRow,
TableSettingsMenu,
TableTitle,
TableTitleActions,
} from '@/portainer/components/datatables/components';
import { multiple } from '@/portainer/components/datatables/components/filter-types';
import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
import { ColumnVisibilityMenu } from '@/portainer/components/datatables/components/ColumnVisibilityMenu';
import { useRepeater } from '@/portainer/components/datatables/components/useRepeater';
import { useDebounce } from '@/portainer/hooks/useDebounce';
import {
useSearchBarContext,
SearchBar,
} from '@/portainer/components/datatables/components/SearchBar';
import { useRowSelect } from '@/portainer/components/datatables/components/useRowSelect';
import { TableFooter } from '@/portainer/components/datatables/components/TableFooter';
import { SelectedRowsCount } from '@/portainer/components/datatables/components/SelectedRowsCount';
import { EdgeDeviceTableSettings } from '@/edge/devices/types';
import { EdgeDevicesDatatableSettings } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableSettings';
import { EdgeDevicesDatatableActions } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableActions';
import { AMTDevicesDatatable } from '@/edge/devices/components/AMTDevicesDatatable/AMTDevicesDatatable';
import { useColumns } from './columns';
export interface EdgeDevicesTableProps {
isEnabled: boolean;
isFdoEnabled: boolean;
isOpenAmtEnabled: boolean;
dataset: Environment[];
onRefresh(): Promise<void>;
setLoadingMessage(message: string): void;
}
export function EdgeDevicesDatatable({
isFdoEnabled,
isOpenAmtEnabled,
dataset,
onRefresh,
setLoadingMessage,
}: EdgeDevicesTableProps) {
const { settings, setTableSettings } =
useTableSettings<EdgeDeviceTableSettings>();
const [searchBarValue, setSearchBarValue] = useSearchBarContext();
const columns = useColumns();
useRepeater(settings.autoRefreshRate, onRefresh);
const {
getTableProps,
getTableBodyProps,
headerGroups,
page,
prepareRow,
selectedFlatRows,
allColumns,
gotoPage,
setPageSize,
setHiddenColumns,
setGlobalFilter,
state: { pageIndex, pageSize },
} = useTable<Environment>(
{
defaultCanFilter: false,
columns,
data: dataset,
filterTypes: { multiple },
initialState: {
pageSize: settings.pageSize || 10,
hiddenColumns: settings.hiddenColumns,
sortBy: [settings.sortBy],
globalFilter: searchBarValue,
},
isRowSelectable() {
return true;
},
selectColumnWidth: 5,
},
useFilters,
useGlobalFilter,
useSortBy,
useExpanded,
usePagination,
useRowSelect,
useRowSelectColumn
);
const debouncedSearchValue = useDebounce(searchBarValue);
useEffect(() => {
setGlobalFilter(debouncedSearchValue);
}, [debouncedSearchValue, setGlobalFilter]);
const columnsToHide = allColumns.filter((colInstance) => {
const columnDef = columns.find((c) => c.id === colInstance.id);
return columnDef?.canHide;
});
const tableProps = getTableProps();
const tbodyProps = getTableBodyProps();
return (
<TableContainer>
<TableTitle icon="fa-plug" label="Edge Devices">
<TableTitleActions>
<ColumnVisibilityMenu<Environment>
columns={columnsToHide}
onChange={handleChangeColumnsVisibility}
value={settings.hiddenColumns}
/>
<TableSettingsMenu>
<EdgeDevicesDatatableSettings />
</TableSettingsMenu>
</TableTitleActions>
</TableTitle>
<TableActions>
<EdgeDevicesDatatableActions
selectedItems={selectedFlatRows.map((row) => row.original)}
isFDOEnabled={isFdoEnabled}
isOpenAMTEnabled={isOpenAmtEnabled}
setLoadingMessage={setLoadingMessage}
/>
</TableActions>
<SearchBar
value={searchBarValue}
onChange={handleSearchBarChange}
autoFocus={false}
/>
<Table
className={tableProps.className}
role={tableProps.role}
style={tableProps.style}
>
<thead>
{headerGroups.map((headerGroup) => {
const { key, className, role, style } =
headerGroup.getHeaderGroupProps();
return (
<TableHeaderRow<Environment>
key={key}
className={className}
role={role}
style={style}
headers={headerGroup.headers}
onSortChange={handleSortChange}
/>
);
})}
</thead>
<tbody
className={tbodyProps.className}
role={tbodyProps.role}
style={tbodyProps.style}
>
{page.map((row) => {
prepareRow(row);
const { key, className, role, style } = row.getRowProps();
return (
<Fragment key={key}>
<TableRow<Environment>
cells={row.cells}
key={key}
className={className}
role={role}
style={style}
/>
{row.isExpanded && (
<tr>
<td />
<td colSpan={row.cells.length - 1}>
<AMTDevicesDatatable environmentId={row.original.Id} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</Table>
<TableFooter>
<SelectedRowsCount value={selectedFlatRows.length} />
<PaginationControls
showAll
pageLimit={pageSize}
page={pageIndex + 1}
onPageChange={(p) => gotoPage(p - 1)}
totalCount={dataset.length}
onPageLimitChange={handlePageSizeChange}
/>
</TableFooter>
</TableContainer>
);
function handlePageSizeChange(pageSize: number) {
setPageSize(pageSize);
setTableSettings((settings) => ({ ...settings, pageSize }));
}
function handleChangeColumnsVisibility(hiddenColumns: string[]) {
setHiddenColumns(hiddenColumns);
setTableSettings((settings) => ({ ...settings, hiddenColumns }));
}
function handleSearchBarChange(value: string) {
setSearchBarValue(value);
}
function handleSortChange(id: string, desc: boolean) {
setTableSettings((settings) => ({
...settings,
sortBy: { id, desc },
}));
}
}

View File

@@ -0,0 +1,134 @@
import { useRouter } from '@uirouter/react';
import type { Environment } from '@/portainer/environments/types';
import { Button } from '@/portainer/components/Button';
import { confirmAsync } from '@/portainer/services/modal.service/confirm';
import { promptAsync } from '@/portainer/services/modal.service/prompt';
import * as notifications from '@/portainer/services/notifications';
import { activateDevice } from '@/portainer/hostmanagement/open-amt/open-amt.service';
interface Props {
selectedItems: Environment[];
isFDOEnabled: boolean;
isOpenAMTEnabled: boolean;
setLoadingMessage(message: string): void;
}
export function EdgeDevicesDatatableActions({
selectedItems,
isOpenAMTEnabled,
isFDOEnabled,
setLoadingMessage,
}: Props) {
const hasSelectedItem = selectedItems.length === 1;
const router = useRouter();
return (
<div className="actionBar">
{(isFDOEnabled || isOpenAMTEnabled) && (
<Button onClick={() => onAddNewDeviceClick()}>
<i className="fa fa-plus-circle space-right" aria-hidden="true" />
Add new
</Button>
)}
{isOpenAMTEnabled && (
<Button
disabled={!hasSelectedItem}
onClick={() => onAssociateOpenAMTClick(selectedItems)}
>
<i className="fa fa-link space-right" aria-hidden="true" />
Associate with OpenAMT
</Button>
)}
</div>
);
async function onAddNewDeviceClick() {
if (!isFDOEnabled) {
router.stateService.go('portainer.endpoints.newEdgeDevice');
return;
}
if (!isOpenAMTEnabled) {
router.stateService.go('portainer.endpoints.importDevice');
return;
}
const result = await promptAsync({
title: 'How would you like to add an Edge Device?',
inputType: 'radio',
inputOptions: [
{
text: 'Provision bare-metal using Intel FDO',
value: 'FDO',
},
{
text: 'Deploy agent manually',
value: 'AMT',
},
],
buttons: {
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
switch (result) {
case 'FDO':
router.stateService.go('portainer.endpoints.importDevice');
break;
case 'AMT':
router.stateService.go('portainer.endpoints.newEdgeDevice');
break;
default:
break;
}
}
async function onAssociateOpenAMTClick(selectedItems: Environment[]) {
const selectedEnvironment = selectedItems[0];
const confirmed = await confirmAsync({
title: '',
message: `Associate ${selectedEnvironment.Name} with OpenAMT`,
buttons: {
cancel: {
label: 'Cancel',
className: 'btn-default',
},
confirm: {
label: 'Confirm',
className: 'btn-primary',
},
},
});
if (!confirmed) {
return;
}
try {
setLoadingMessage(
'Activating Active Management Technology on selected device...'
);
await activateDevice(selectedEnvironment.Id);
notifications.success(
'Successfully associated with OpenAMT',
selectedEnvironment.Name
);
} catch (err) {
notifications.error(
'Failure',
err as Error,
'Unable to associate with OpenAMT'
);
} finally {
setLoadingMessage('');
}
}
}

View File

@@ -0,0 +1,40 @@
import { react2angular } from '@/react-tools/react2angular';
import { TableSettingsProvider } from '@/portainer/components/datatables/components/useTableSettings';
import { SearchBarProvider } from '@/portainer/components/datatables/components/SearchBar';
import {
EdgeDevicesDatatable,
EdgeDevicesTableProps,
} from './EdgeDevicesDatatable';
export function EdgeDevicesDatatableContainer({
...props
}: EdgeDevicesTableProps) {
const defaultSettings = {
autoRefreshRate: 0,
hiddenQuickActions: [],
hiddenColumns: [],
pageSize: 10,
sortBy: { id: 'state', desc: false },
};
return (
<TableSettingsProvider defaults={defaultSettings} storageKey="edgeDevices">
<SearchBarProvider>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<EdgeDevicesDatatable {...props} />
</SearchBarProvider>
</TableSettingsProvider>
);
}
export const EdgeDevicesDatatableAngular = react2angular(
EdgeDevicesDatatableContainer,
[
'dataset',
'onRefresh',
'setLoadingMessage',
'isOpenAmtEnabled',
'isFdoEnabled',
]
);

View File

@@ -0,0 +1,19 @@
import { TableSettingsMenuAutoRefresh } from '@/portainer/components/datatables/components/TableSettingsMenuAutoRefresh';
import { useTableSettings } from '@/portainer/components/datatables/components/useTableSettings';
import { EdgeDeviceTableSettings } from '@/edge/devices/types';
export function EdgeDevicesDatatableSettings() {
const { settings, setTableSettings } =
useTableSettings<EdgeDeviceTableSettings>();
return (
<TableSettingsMenuAutoRefresh
value={settings.autoRefreshRate}
onChange={handleRefreshRateChange}
/>
);
function handleRefreshRateChange(autoRefreshRate: number) {
setTableSettings({ autoRefreshRate });
}
}

View File

@@ -0,0 +1,36 @@
import { CellProps, Column, TableInstance } from 'react-table';
import { MenuItem, MenuLink } from '@reach/menu-button';
import { useSref } from '@uirouter/react';
import { Environment } from '@/portainer/environments/types';
import { ActionsMenu } from '@/portainer/components/datatables/components/ActionsMenu';
export const actions: Column<Environment> = {
Header: 'Actions',
accessor: () => 'actions',
id: 'actions',
disableFilters: true,
canHide: true,
disableResizing: true,
width: '5px',
sortType: 'string',
Filter: () => null,
Cell: ActionsCell,
};
export function ActionsCell({
row: { original: environment },
}: CellProps<TableInstance>) {
const browseLinkProps = useSref('portainer.endpoints.endpoint', {
id: environment.Id,
});
return (
<ActionsMenu>
<MenuLink href={browseLinkProps.href} onClick={browseLinkProps.onClick}>
Browse
</MenuLink>
<MenuItem onSelect={() => {}}>Refresh Snapshot</MenuItem>
</ActionsMenu>
);
}

View File

@@ -0,0 +1,12 @@
import { Column } from 'react-table';
import { Environment } from '@/portainer/environments/types';
import { DefaultFilter } from '@/portainer/components/datatables/components/Filter';
export const group: Column<Environment> = {
Header: 'Group',
accessor: (row) => row.GroupName || '-',
id: 'groupName',
Filter: DefaultFilter,
canHide: true,
};

View File

@@ -0,0 +1,29 @@
import { Column } from 'react-table';
import clsx from 'clsx';
import { Environment, EnvironmentStatus } from '@/portainer/environments/types';
import { DefaultFilter } from '@/portainer/components/datatables/components/Filter';
export const heartbeat: Column<Environment> = {
Header: 'Heartbeat',
accessor: (row) => (row.Status === EnvironmentStatus.Up ? 'up' : 'down'),
id: 'status',
Cell: StatusCell,
Filter: DefaultFilter,
canHide: true,
};
function StatusCell({ value: status }: { value: string }) {
return (
<span className={clsx('label', `label-${environmentStatusBadge(status)}`)}>
{status}
</span>
);
function environmentStatusBadge(status: string) {
if (status === 'down') {
return 'danger';
}
return 'success';
}
}

View File

@@ -0,0 +1,10 @@
import { useMemo } from 'react';
import { name } from './name';
import { heartbeat } from './heartbeat';
import { group } from './group';
import { actions } from './actions';
export function useColumns() {
return useMemo(() => [name, heartbeat, group, actions], []);
}

View File

@@ -0,0 +1,30 @@
import { CellProps, Column, TableInstance } from 'react-table';
import { Environment } from '@/portainer/environments/types';
import { Link } from '@/portainer/components/Link';
import { ExpandingCell } from '@/portainer/components/datatables/components/ExpandingCell';
export const name: Column<Environment> = {
Header: 'Name',
accessor: (row) => row.Name,
id: 'name',
Cell: NameCell,
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
};
export function NameCell({ value: name, row }: CellProps<TableInstance>) {
return (
<ExpandingCell row={row}>
<Link
to="portainer.endpoints.endpoint"
params={{ id: row.original.Id }}
title={name}
>
{name}
</Link>
</ExpandingCell>
);
}

View File

@@ -0,0 +1,7 @@
import angular from 'angular';
import { EdgeDevicesDatatableAngular } from '@/edge/devices/components/EdgeDevicesDatatable/EdgeDevicesDatatableContainer';
export default angular
.module('portainer.edge.devices', [])
.component('edgeDevicesDatatable', EdgeDevicesDatatableAngular).name;

31
app/edge/devices/types.ts Normal file
View File

@@ -0,0 +1,31 @@
export interface EdgeDeviceTableSettings {
hiddenColumns: string[];
autoRefreshRate: number;
pageSize: number;
sortBy: { id: string; desc: boolean };
}
export enum DeviceAction {
POWER_ON = 'power on',
POWER_OFF = 'power off',
RESTART = 'restart',
}
export enum PowerState {
RUNNING = 'Running',
SLEEP = 'Sleep',
OFF = 'Off',
HIBERNATE = 'Hibernate',
POWER_CYCLE = 'Power Cycle',
}
export enum PowerStateCode {
ON = 2,
SLEEP_LIGHT = 3,
SLEEP_DEEP = 4,
OFF_HARD = 6,
HIBERNATE = 7,
OFF_SOFT = 8,
POWER_CYCLE = 9,
OFF_HARD_GRACEFUL = 13,
}

View File

@@ -1,6 +1,6 @@
import angular from 'angular';
import { ScheduleCreateRequest, ScheduleUpdateRequest } from 'Portainer/models/schedule';
import { ScheduleCreateRequest, ScheduleUpdateRequest } from '@/portainer/models/schedule';
function EdgeJobService(EdgeJobs, EdgeJobResults, FileUploadService) {
var service = {};

View File

@@ -0,0 +1,37 @@
<rd-header>
<rd-header-title title-text="Edge Devices">
<a data-toggle="tooltip" title="Refresh" ui-sref="edge.devices" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Edge Devices</rd-header-content>
</rd-header>
<div
class="row"
style="width: 100%; height: 100%; text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center"
ng-if="$ctrl.loadingMessage"
>
<div class="sk-fold">
<div class="sk-fold-cube"></div>
<div class="sk-fold-cube"></div>
<div class="sk-fold-cube"></div>
<div class="sk-fold-cube"></div>
</div>
<span style="margin-top: 25px">
{{ $ctrl.loadingMessage }}
<i class="fa fa-cog fa-spin"></i>
</span>
</div>
<div class="row" ng-if="!$ctrl.loadingMessage">
<div class="col-sm-12">
<edge-devices-datatable
dataset="($ctrl.edgeDevices)"
is-fdo-enabled="($ctrl.isFDOEnabled)"
is-open-amt-enabled="($ctrl.isOpenAMTEnabled)"
on-refresh="($ctrl.getEnvironments)"
set-loading-message="($ctrl.setLoadingMessage)"
></edge-devices-datatable>
</div>
</div>

View File

@@ -0,0 +1,49 @@
import EndpointHelper from 'Portainer/helpers/endpointHelper';
import { getEndpoints } from 'Portainer/environments/environment.service';
angular.module('portainer.edge').controller('EdgeDevicesViewController', EdgeDevicesViewController);
/* @ngInject */
export function EdgeDevicesViewController($q, $async, EndpointService, GroupService, SettingsService, ModalService, Notifications) {
var ctrl = this;
ctrl.edgeDevices = [];
this.getEnvironments = function () {
return $async(async () => {
try {
const [endpointsResponse, groups] = await Promise.all([getEndpoints(0, 100, {edgeDeviceFilter: true}), GroupService.groups()])
EndpointHelper.mapGroupNameToEndpoint(endpointsResponse.value, groups);
ctrl.edgeDevices = endpointsResponse.value;
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve edge devices');
ctrl.edgeDevices = [];
}
});
}
this.getSettings = function () {
return $async(async () => {
try {
const settings = await SettingsService.settings();
ctrl.isOpenAMTEnabled = settings && settings.EnableEdgeComputeFeatures && settings.openAMTConfiguration && settings.openAMTConfiguration.enabled;
ctrl.isFDOEnabled = settings && settings.EnableEdgeComputeFeatures && settings.fdoConfiguration && settings.fdoConfiguration.enabled;
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve settings');
}
});
};
this.setLoadingMessage = function (message) {
return $async(async () => {
ctrl.loadingMessage = message;
});
};
function initView() {
ctrl.getEnvironments();
ctrl.getSettings();
}
initView();
}

View File

@@ -0,0 +1,8 @@
import angular from 'angular';
import { EdgeDevicesViewController } from './edgeDevicesViewController';
angular.module('portainer.edge').component('edgeDevicesView', {
templateUrl: './edgeDevicesView.html',
controller: EdgeDevicesViewController,
});

View File

@@ -32,7 +32,7 @@
<uib-tab-heading> <i class="fa fa-tasks" aria-hidden="true"></i> Results </uib-tab-heading>
<edge-job-results-datatable
style="display: block; margin-top: 10px;"
style="display: block; margin-top: 10px"
ng-if="$ctrl.results"
title-text="Results"
title-icon="fa-tasks"

View File

@@ -10,9 +10,7 @@
<form class="form-horizontal" name="$ctrl.form">
<!-- name-input -->
<div class="form-group">
<label for="stack_name" class="col-sm-1 control-label text-left">
Name
</label>
<label for="stack_name" class="col-sm-1 control-label text-left"> Name </label>
<div class="col-sm-11">
<input
type="text"
@@ -29,9 +27,7 @@
</div>
<!-- !name-input -->
<div class="col-sm-12 form-section-title">
Edge Groups
</div>
<div class="col-sm-12 form-section-title"> Edge Groups </div>
<div class="form-group" ng-if="$ctrl.edgeGroups">
<div class="col-sm-12">
<edge-groups-selector ng-if="!$ctrl.noGroups" model="$ctrl.formValues.Groups" on-change="($ctrl.onChangeGroups)" items="$ctrl.edgeGroups"></edge-groups-selector>
@@ -62,9 +58,7 @@
<edge-stacks-kube-manifest-form ng-if="$ctrl.formValues.DeploymentType == 1" form-values="$ctrl.formValues" state="$ctrl.state"></edge-stacks-kube-manifest-form>
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12">
<button
@@ -82,7 +76,7 @@
<span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span>
<span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span>
</button>
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px">
{{ $ctrl.state.formValidationError }}
</span>
</div>

View File

@@ -1,6 +1,4 @@
<div class="col-sm-12 form-section-title">
Build method
</div>
<div class="col-sm-12 form-section-title"> Build method </div>
<box-selector radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<web-editor-form
@@ -14,17 +12,13 @@
>
<editor-description>
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>
<a href="https://docs.docker.com/compose/compose-file/" target="_blank"> official documentation </a>
.
</editor-description>
</web-editor-form>
<file-upload-form ng-if="$ctrl.state.Method === 'upload'" file="$ctrl.formValues.StackFile" on-change="($ctrl.onChangeFile)" ng-required="true">
<file-upload-description>
You can upload a Compose file from your computer.
</file-upload-description>
<file-upload-description> You can upload a Compose file from your computer. </file-upload-description>
</file-upload-form>
<git-form ng-if="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
@@ -32,9 +26,7 @@
<!-- template -->
<div ng-if="$ctrl.state.Method === 'template'">
<div class="form-group">
<label for="stack_template" class="col-sm-1 control-label text-left">
Template
</label>
<label for="stack_template" class="col-sm-1 control-label text-left"> Template </label>
<div class="col-sm-11">
<select
class="form-control"
@@ -48,9 +40,7 @@
</div>
<!-- description -->
<div ng-if="$ctrl.selectedTemplate.note">
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="col-sm-12 form-section-title"> Information </div>
<div class="form-group">
<div class="col-sm-12">
<div class="template-note" ng-bind-html="$ctrl.selectedTemplate.note"></div>

View File

@@ -1,5 +1,5 @@
<p>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...).
</p>
<p>

View File

@@ -1,6 +1,4 @@
<div class="col-sm-12 form-section-title">
Build method
</div>
<div class="col-sm-12 form-section-title"> Build method </div>
<box-selector radio-name="'method'" value="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<web-editor-form

View File

@@ -15,7 +15,7 @@
<uib-tab index="0" classes="btn-sm">
<uib-tab-heading> <i class="fa fa-layer-group space-right" aria-hidden="true"></i> Stack</uib-tab-heading>
<div style="padding: 20px;">
<div style="padding: 20px">
<edit-edge-stack-form
edge-groups="$ctrl.edgeGroups"
model="$ctrl.formValues"
@@ -28,7 +28,7 @@
<uib-tab index="1" classes="btn-sm">
<uib-tab-heading> <i class="fa fa-plug space-right" aria-hidden="true"></i> Environments</uib-tab-heading>
<div style="margin-top: 25px;">
<div style="margin-top: 25px">
<edge-stack-endpoints-datatable
title-text="Environments Status"
dataset="$ctrl.endpoints"

2
app/global.d.ts vendored
View File

@@ -6,3 +6,5 @@ declare module '*.png' {
}
declare module '*.css';
declare module '@open-amt-cloud-toolkit/ui-toolkit-react/reactjs/src/kvm.bundle';

View File

@@ -205,6 +205,31 @@ angular
},
};
var edgeDeviceCreation = {
name: 'portainer.endpoints.newEdgeDevice',
url: '/newEdgeDevice',
params: {
isEdgeDevice: true,
},
views: {
'content@': {
templateUrl: './views/endpoints/create/createendpoint.html',
controller: 'CreateEndpointController',
},
},
};
var deviceImport = {
name: 'portainer.endpoints.importDevice',
url: '/device',
views: {
'content@': {
templateUrl: './views/devices/import/importDevice.html',
controller: 'ImportDeviceController',
},
},
};
var endpointAccess = {
name: 'portainer.endpoints.endpoint.access',
url: '/access',
@@ -217,6 +242,17 @@ angular
},
};
var endpointKVM = {
name: 'portainer.endpoints.endpoint.kvm',
url: '/kvm?deviceId&deviceName',
views: {
'content@': {
templateUrl: './views/endpoints/kvm/endpointKVM.html',
controller: 'EndpointKVMController',
},
},
};
var groups = {
name: 'portainer.groups',
url: '/groups',
@@ -378,6 +414,16 @@ angular
},
};
var settingsEdgeCompute = {
name: 'portainer.settings.edgeCompute',
url: '/edge',
views: {
'content@': {
component: 'settingsEdgeComputeView',
},
},
};
var tags = {
name: 'portainer.tags',
url: '/tags',
@@ -444,7 +490,10 @@ angular
$stateRegistryProvider.register(endpoint);
$stateRegistryProvider.register(k8sendpoint);
$stateRegistryProvider.register(endpointAccess);
$stateRegistryProvider.register(endpointKVM);
$stateRegistryProvider.register(edgeDeviceCreation);
$stateRegistryProvider.register(endpointCreation);
$stateRegistryProvider.register(deviceImport);
$stateRegistryProvider.register(endpointKubernetesConfiguration);
$stateRegistryProvider.register(groups);
$stateRegistryProvider.register(group);
@@ -461,6 +510,7 @@ angular
$stateRegistryProvider.register(registryCreation);
$stateRegistryProvider.register(settings);
$stateRegistryProvider.register(settingsAuthentication);
$stateRegistryProvider.register(settingsEdgeCompute);
$stateRegistryProvider.register(tags);
$stateRegistryProvider.register(users);
$stateRegistryProvider.register(user);

View File

@@ -3,14 +3,23 @@ import clsx from 'clsx';
import styles from './TextTip.module.css';
export function TextTip({ children }: PropsWithChildren<unknown>) {
type Color = 'orange' | 'blue';
export interface Props {
color?: Color;
}
export function TextTip({
color = 'orange',
children,
}: PropsWithChildren<Props>) {
return (
<p className="text-muted small">
<i
aria-hidden="true"
className={clsx(
'fa fa-exclamation-circle',
'orange-icon',
`${color}-icon`,
styles.textMargin
)}
/>

View File

@@ -0,0 +1,25 @@
.actions {
float: right;
margin: 5px 10px 0 0;
}
.actions-active {
color: var(--blue-2);
}
.table-actions-menu-list {
padding: 0 10px 0 10px;
}
.table-actions-menu-list [data-reach-menu-item] {
padding: 5px 5px !important;
}
.table-actions-menu-btn {
border: none;
background: none;
}
[data-reach-menu-link] {
text-decoration: none !important;
}

View File

@@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import clsx from 'clsx';
import { Menu, MenuList, MenuButton } from '@reach/menu-button';
import styles from './ActionsMenu.module.css';
interface Props {
children: ReactNode;
}
export function ActionsMenu({ children }: Props) {
return (
<Menu className={styles.actions}>
{({ isExpanded }) => (
<>
<MenuButton
className={clsx(
styles.tableActionsMenuBtn,
isExpanded && styles.actionsActive
)}
>
<i className="fa fa-ellipsis-v" aria-hidden="true" />
</MenuButton>
<MenuList>
<div className={styles.tableActionsMenuList}>{children}</div>
</MenuList>
</>
)}
</Menu>
);
}

View File

@@ -0,0 +1,3 @@
.table-actions-title {
color: var(--blue-2);
}

View File

@@ -0,0 +1,11 @@
import { ReactNode } from 'react';
import styles from './ActionsMenuTitle.module.css';
interface Props {
children: ReactNode;
}
export function ActionsMenuTitle({ children }: Props) {
return <div className={styles.tableActionsTitle}>{children}</div>;
}

View File

@@ -3,17 +3,16 @@ import { Menu, MenuButton, MenuList } from '@reach/menu-button';
import { ColumnInstance } from 'react-table';
import { Checkbox } from '@/portainer/components/form-components/Checkbox';
import type { DockerContainer } from '@/docker/containers/types';
import { useTableContext } from './TableContainer';
interface Props {
columns: ColumnInstance<DockerContainer>[];
interface Props<D extends object> {
columns: ColumnInstance<D>[];
onChange: (value: string[]) => void;
value: string[];
}
export function ColumnVisibilityMenu({ columns, onChange, value }: Props) {
export function ColumnVisibilityMenu<D extends object>({ columns, onChange, value }: Props<D>) {
useTableContext();
return (

View File

@@ -0,0 +1,26 @@
import { PropsWithChildren } from 'react';
import { Row, TableInstance } from 'react-table';
interface Props {
row: Row<TableInstance>;
}
export function ExpandingCell({ children, row }: PropsWithChildren<Props>) {
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<div {...row.getToggleRowExpandedProps({})}>
<i
className={`fas ${arrowClass(row.isExpanded)} space-right`}
aria-hidden="true"
/>
{children}
</div>
);
function arrowClass(isExpanded: boolean) {
if (isExpanded) {
return 'fa-angle-down';
}
return 'fa-angle-right';
}
}

View File

@@ -0,0 +1,3 @@
.inner-datatable thead {
background-color: var(--bg-inner-datatable-thead) !important;
}

View File

@@ -0,0 +1,7 @@
import { PropsWithChildren } from 'react';
import styles from './InnerDatatable.module.css';
export function InnerDatatable({ children }: PropsWithChildren<unknown>) {
return <div className={styles.innerDatatable}>{children}</div>;
}

View File

@@ -16,9 +16,8 @@ export interface QuickActionsSettingsType {
}
export function QuickActionsSettings({ actions }: Props) {
const { settings, setTableSettings } = useTableSettings<
QuickActionsSettingsType
>();
const { settings, setTableSettings } =
useTableSettings<QuickActionsSettingsType>();
return (
<>

View File

@@ -5,7 +5,7 @@ import { TableHeaderCell } from './TableHeaderCell';
interface Props<D extends Record<string, unknown> = Record<string, unknown>> {
headers: HeaderGroup<D>[];
onSortChange(colId: string, desc: boolean): void;
onSortChange?(colId: string, desc: boolean): void;
}
export function TableHeaderRow<
@@ -26,13 +26,18 @@ export function TableHeaderRow<
headerProps={{
...column.getHeaderProps({
className: column.className,
style: {
width: column.disableResizing ? column.width : '',
},
}),
}}
key={column.id}
canSort={column.canSort}
onSortClick={(desc) => {
column.toggleSortBy(desc);
onSortChange(column.id, desc);
if (onSortChange) {
onSortChange(column.id, desc);
}
}}
isSorted={column.isSorted}
isSortedDesc={column.isSortedDesc}

View File

@@ -5,7 +5,7 @@ import { PropsWithChildren, ReactNode } from 'react';
import { useTableContext } from './TableContainer';
interface Props {
quickActions: ReactNode;
quickActions?: ReactNode;
}
export function TableSettingsMenu({

View File

@@ -71,7 +71,7 @@ export function TableSettingsProvider<T>({
}
function getContextType<T>() {
return (TableSettingsContext as unknown) as Context<
return TableSettingsContext as unknown as Context<
TableSettingsContextInterface<T>
>;
}

View File

@@ -277,4 +277,4 @@
.datatable .table-setting-menu-btn {
border: none;
background: none;
}
}

View File

@@ -15,7 +15,7 @@
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.endpoints.new" data-cy="endpoint-addEndpointButton">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add environment
<i class="fa fa-plus-circle space-right" aria-hidden="true"></i>Add environment
</button>
</div>
<div class="searchBar">

View File

@@ -3,6 +3,7 @@ angular.module('portainer.app').controller('EndpointsDatatableController', [
'$controller',
'DatatableService',
'PaginationService',
'Notifications',
function ($scope, $controller, DatatableService, PaginationService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));

View File

@@ -1,3 +1,7 @@
.file-input {
display: none !important;
}
.file-button {
margin-left: 0 !important;
}

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