Compare commits
297 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cc3f6bab4 | |||
| 6330cc885c | |||
| cd3381ee59 | |||
| 7b7234c79a | |||
| a965f5d538 | |||
| b31176da7a | |||
| aacf4eb880 | |||
| b19e9906fc | |||
| 576456efb2 | |||
| 103fbbfe6e | |||
| c8044cc125 | |||
| 154f2ed189 | |||
| 1a2591f0cf | |||
| dacd188fa0 | |||
| 4be094cb29 | |||
| 2c9328d765 | |||
| ede9bc6111 | |||
| 2abb6650ae | |||
| cc73724351 | |||
| 6fd5ebbf55 | |||
| aad0b139fc | |||
| a8157caa67 | |||
| f69a6129e3 | |||
| e7d0bd6b65 | |||
| 956a6751a2 | |||
| de6ef0de83 | |||
| 8f8e89af3e | |||
| 9d45a25e99 | |||
| c471b23bc5 | |||
| 391d53cdc2 | |||
| 3fe8697159 | |||
| 4dabe333f9 | |||
| 05e3634594 | |||
| de3c75e701 | |||
| de5dc93b5f | |||
| 39896a6eaf | |||
| 8b6bd59003 | |||
| 750a37b861 | |||
| 8a6593c7f5 | |||
| 6efd3c6a57 | |||
| 8b21023c9d | |||
| 0aebe38c31 | |||
| 28c4b333ce | |||
| e4c7561dfc | |||
| 750bb9cf87 | |||
| c5f5269366 | |||
| b9b8d78fcc | |||
| 36c93c7f57 | |||
| b67f404d8d | |||
| 95fb5a4baa | |||
| dd372637cb | |||
| c1a4856e9d | |||
| 92b7e64689 | |||
| a750259a2c | |||
| 87accfce5d | |||
| 29f0daa7ea | |||
| a247db7e93 | |||
| 1fbaf5fcbf | |||
| c981e6ff7b | |||
| ee1ee633d7 | |||
| a7ab0a5662 | |||
| bed4257194 | |||
| 5ee570e075 | |||
| 9666c21b8a | |||
| 5cf789a8e4 | |||
| 6a4a353b92 | |||
| 02355acfa8 | |||
| 04eb718f88 | |||
| 36888b5ad4 | |||
| 7bd971f838 | |||
| c3ce4d8b53 | |||
| fb3a31a4fd | |||
| b6852b5e30 | |||
| 34e2178752 | |||
| 83a17de1c0 | |||
| e5b27d7a57 | |||
| fb14a85483 | |||
| 8d4cb5e16b | |||
| ad8b8399c4 | |||
| 8ff2fa66b6 | |||
| 539948b5a6 | |||
| bfe1cace77 | |||
| 7b806cf586 | |||
| 69a824c25b | |||
| 8d733ccc8c | |||
| 2574f223b4 | |||
| f2d93654f5 | |||
| 5e74b90780 | |||
| 78ce176268 | |||
| dfb398d091 | |||
| 4e9b3a8940 | |||
| 0141e55936 | |||
| 46fba176f0 | |||
| 441e265c32 | |||
| d28030abea | |||
| aa0f1221de | |||
| 305a949692 | |||
| 31d3fd730c | |||
| a46002502f | |||
| 56fcc91e30 | |||
| 8a8058e4eb | |||
| 20a66fb10f | |||
| 628f822025 | |||
| d8db8718bd | |||
| 5b40c79ea3 | |||
| ae9025c1fb | |||
| 0014e39b61 | |||
| 5d1ea8ceb2 | |||
| 079478f191 | |||
| 65c050dc87 | |||
| 21fbd37bfb | |||
| b28f635fb2 | |||
| 0580d3833a | |||
| bff9bb7800 | |||
| fb3d333453 | |||
| 2c25e1d48e | |||
| 5469392ec7 | |||
| e1c7079c81 | |||
| 75c1b485ab | |||
| 03590d46e6 | |||
| 9dc6aa81cb | |||
| d0b88d7e2f | |||
| 5343b965aa | |||
| 104c82c54e | |||
| c0569a0752 | |||
| ad86b6b11f | |||
| ff32e87b97 | |||
| 1e78234f04 | |||
| d0a9c046b3 | |||
| c54bb255ba | |||
| 8843b7b0e8 | |||
| a95d734c34 | |||
| 8262487401 | |||
| 57e53d1a21 | |||
| e28a1491d4 | |||
| 9342ba9792 | |||
| 2552eb5e25 | |||
| ddaf9dc885 | |||
| 11c778cfeb | |||
| 11dffdee9a | |||
| d4d80ed8f7 | |||
| 0ba10b44ec | |||
| 0f617f7f87 | |||
| 423dd5e394 | |||
| 44737029a9 | |||
| ce22544c60 | |||
| 9106e74e61 | |||
| 6c57ddb563 | |||
| a2e1570162 | |||
| ea60740d48 | |||
| 762c664948 | |||
| d574a71cb1 | |||
| bb066cd58c | |||
| e779939ae1 | |||
| aa830a0e58 | |||
| 52ac54f15c | |||
| cc0ab75aca | |||
| 7e3347da2b | |||
| 87e9d7f8d4 | |||
| 6d3a33635d | |||
| 090268d7b6 | |||
| 698a91596e | |||
| bb447bb02a | |||
| 5ffcbe8677 | |||
| ac6296b86d | |||
| 3239a61bda | |||
| 2a43285593 | |||
| 36071837cb | |||
| 1ef713d80b | |||
| 82b848af0c | |||
| b059641c80 | |||
| 728e885b9d | |||
| 3acefba069 | |||
| 9205f67791 | |||
| 6d95643a68 | |||
| 149c414d08 | |||
| f8b4663e0a | |||
| 7b774c702d | |||
| 8045a15a50 | |||
| 9a18dd8162 | |||
| 70a7eefa22 | |||
| 3356d1abe2 | |||
| 7ee8dac832 | |||
| 5b3f099f4e | |||
| 5f5cb36df1 | |||
| 3645ff7459 | |||
| 9a92b97b7e | |||
| 005c48b1ad | |||
| 4fb1880ddc | |||
| 54145ce949 | |||
| b040aa1e78 | |||
| 985eef6987 | |||
| a5c3116b0c | |||
| df381b6a33 | |||
| 9223c0226a | |||
| 314fdc850e | |||
| 43bbeed141 | |||
| e07253bcef | |||
| 23b9baa059 | |||
| 05357ecce5 | |||
| 1a8fe82821 | |||
| 95f4db4f48 | |||
| 43600083a7 | |||
| e6477b0b97 | |||
| 6aa7fdb4f2 | |||
| 4997e9c7be | |||
| f0456cbf5f | |||
| a0d349e0b3 | |||
| f5e774c89d | |||
| 552d3f8a3e | |||
| 39f9173956 | |||
| e4fc41fc94 | |||
| ce7d234cba | |||
| 35701f5899 | |||
| 3d4d2b50ae | |||
| 0da4e3ae63 | |||
| ad7055ee01 | |||
| 8076455423 | |||
| 23eca3ce80 | |||
| 4cc672f902 | |||
| 82fb5f7ac1 | |||
| de59ea030a | |||
| d9be6d1724 | |||
| 958a8e97e9 | |||
| 5fd202d629 | |||
| 768f1aa663 | |||
| 69caa1179f | |||
| 9a2cdc4a93 | |||
| 14a8b1d897 | |||
| 712207e69f | |||
| 8d46692d66 | |||
| 3241738775 | |||
| ce840997bf | |||
| 88c4a43a19 | |||
| b4acbfc9e1 | |||
| 8bf1c91bc9 | |||
| a66fd78dc1 | |||
| b004b33935 | |||
| d32793e84e | |||
| fd4b515350 | |||
| 0cd2a4558b | |||
| 89359a21ce | |||
| 69baa279d4 | |||
| 33861a834b | |||
| dd4d126934 | |||
| 7275d23e4b | |||
| d7306fb22e | |||
| ebc0a8c772 | |||
| f26e1fa21b | |||
| 6b27ba9121 | |||
| 975dc9c1da | |||
| 6fe26a52dd | |||
| cd66e32912 | |||
| 81f8b88541 | |||
| 882051cc30 | |||
| ed8f9b5931 | |||
| e5e57978af | |||
| 75fef397d3 | |||
| 624490716e | |||
| 8eff32ebc7 | |||
| cd19eb036b | |||
| 95f706aabe | |||
| 1551b02fde | |||
| 557f4773cf | |||
| b84e1c8550 | |||
| 46e1a01625 | |||
| 7238372d8d | |||
| 00126cd08a | |||
| 58c44ad1ea | |||
| 84611a90a1 | |||
| f78a6568a6 | |||
| 825269c119 | |||
| 60cd7b5527 | |||
| 767fabe0ce | |||
| f86ba7b176 | |||
| 912250732a | |||
| ae731b5496 | |||
| 92eaa02156 | |||
| 18252ab854 | |||
| 212400c283 | |||
| 8ed41de815 | |||
| 97a880e6c1 | |||
| f39775752d | |||
| 6d6c70a98b | |||
| 461fc91446 | |||
| 8059cae8e7 | |||
| 41107191c3 | |||
| cb6a5fa41d | |||
| 66799a53f4 | |||
| 892fdbf60d | |||
| b6309682ef | |||
| be11dfc231 | |||
| 12527aa820 | |||
| 0d0f9499eb | |||
| 60eab3e263 | |||
| eb547162e9 | |||
| 0864c371e8 |
+17
-2
@@ -31,7 +31,12 @@ rules:
|
||||
[
|
||||
'error',
|
||||
{
|
||||
pathGroups: [{ pattern: '@/**', group: 'internal' }, { pattern: '{Kubernetes,Portainer,Agent,Azure,Docker}/**', group: 'internal' }],
|
||||
pathGroups:
|
||||
[
|
||||
{ pattern: '@@/**', group: 'internal', position: 'after' },
|
||||
{ pattern: '@/**', group: 'internal' },
|
||||
{ pattern: '{Kubernetes,Portainer,Agent,Azure,Docker}/**', group: 'internal' },
|
||||
],
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
pathGroupsExcludedImportTypes: ['internal'],
|
||||
},
|
||||
@@ -41,6 +46,7 @@ settings:
|
||||
'import/resolver':
|
||||
alias:
|
||||
map:
|
||||
- ['@@', './app/react/components']
|
||||
- ['@', './app']
|
||||
extensions: ['.js', '.ts', '.tsx']
|
||||
|
||||
@@ -52,6 +58,7 @@ overrides:
|
||||
parser: '@typescript-eslint/parser'
|
||||
plugins:
|
||||
- '@typescript-eslint'
|
||||
- 'regex'
|
||||
extends:
|
||||
- airbnb
|
||||
- airbnb-typescript
|
||||
@@ -68,7 +75,14 @@ overrides:
|
||||
version: 'detect'
|
||||
rules:
|
||||
import/order:
|
||||
['error', { pathGroups: [{ pattern: '@/**', group: 'internal' }], groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], 'newlines-between': 'always' }]
|
||||
[
|
||||
'error',
|
||||
{
|
||||
pathGroups: [{ pattern: '@@/**', group: 'internal', position: 'after' }, { pattern: '@/**', group: 'internal' }],
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
'newlines-between': 'always',
|
||||
},
|
||||
]
|
||||
func-style: [error, 'declaration']
|
||||
import/prefer-default-export: off
|
||||
no-use-before-define: ['error', { functions: false }]
|
||||
@@ -90,6 +104,7 @@ overrides:
|
||||
'react/jsx-no-bind': off
|
||||
'no-await-in-loop': 'off'
|
||||
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }]
|
||||
'regex/invalid': ['error', [{ 'regex': 'data-feather="(.*)"', 'message': 'Please use `react-feather` package instead' }]]
|
||||
- files:
|
||||
- app/**/*.test.*
|
||||
extends:
|
||||
|
||||
@@ -7,6 +7,7 @@ storybook-static
|
||||
.tmp
|
||||
**/.vscode/settings.json
|
||||
**/.vscode/tasks.json
|
||||
.vscode
|
||||
*.DS_Store
|
||||
|
||||
.eslintcache
|
||||
|
||||
@@ -22,7 +22,7 @@ Please note that the public demo cluster is **reset every 15min**.
|
||||
|
||||
Portainer CE is updated regularly. We aim to do an update release every couple of months.
|
||||
|
||||
**The latest version of Portainer is 2.9.x**. Portainer is on version 2, the second number denotes the month of release.
|
||||
**The latest version of Portainer is 2.13.x**.
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/url"
|
||||
)
|
||||
|
||||
// GetAgentVersionAndPlatform returns the agent version and platform
|
||||
//
|
||||
// it sends a ping to the agent and parses the version and platform from the headers
|
||||
func GetAgentVersionAndPlatform(endpointUrl string, tlsConfig *tls.Config) (portainer.AgentPlatform, string, error) {
|
||||
httpCli := &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
}
|
||||
|
||||
if tlsConfig != nil {
|
||||
httpCli.Transport = &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
}
|
||||
}
|
||||
|
||||
parsedURL, err := url.ParseURL(endpointUrl + "/ping")
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
parsedURL.Scheme = "https"
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
resp, err := httpCli.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return 0, "", fmt.Errorf("Failed request with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
version := resp.Header.Get(portainer.PortainerAgentHeader)
|
||||
if version == "" {
|
||||
return 0, "", errors.New("Version Header is missing")
|
||||
}
|
||||
|
||||
agentPlatformHeader := resp.Header.Get(portainer.HTTPResponseAgentPlatform)
|
||||
if agentPlatformHeader == "" {
|
||||
return 0, "", errors.New("Agent Platform Header is missing")
|
||||
}
|
||||
|
||||
agentPlatformNumber, err := strconv.Atoi(agentPlatformHeader)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
if agentPlatformNumber == 0 {
|
||||
return 0, "", errors.New("Agent platform is invalid")
|
||||
}
|
||||
|
||||
return portainer.AgentPlatform(agentPlatformNumber), version, nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package build
|
||||
|
||||
// Variables to be set during the build time
|
||||
var BuildNumber string
|
||||
var ImageTag string
|
||||
var NodejsVersion string
|
||||
var YarnVersion string
|
||||
var WebpackVersion string
|
||||
var GoVersion string
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/portainer/libhelm"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/build"
|
||||
"github.com/portainer/portainer/api/chisel"
|
||||
"github.com/portainer/portainer/api/cli"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
@@ -743,7 +744,15 @@ func main() {
|
||||
|
||||
for {
|
||||
server := buildServer(flags)
|
||||
logrus.Printf("[INFO] [cmd,main] Starting Portainer version %s\n", portainer.APIVersion)
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"Version": portainer.APIVersion,
|
||||
"BuildNumber": build.BuildNumber,
|
||||
"ImageTag": build.ImageTag,
|
||||
"NodejsVersion": build.NodejsVersion,
|
||||
"YarnVersion": build.YarnVersion,
|
||||
"WebpackVersion": build.WebpackVersion,
|
||||
"GoVersion": build.GoVersion},
|
||||
).Print("[INFO] [cmd,main] Starting Portainer")
|
||||
err := server.Start()
|
||||
logrus.Printf("[INFO] [cmd,main] Http server exited: %v\n", err)
|
||||
}
|
||||
|
||||
+19
-1
@@ -103,8 +103,26 @@ func (store *Store) backupWithOptions(options *BackupOptions) (string, error) {
|
||||
store.createBackupFolders()
|
||||
|
||||
options = store.setupOptions(options)
|
||||
dbPath := store.databasePath()
|
||||
|
||||
return options.BackupPath, store.copyDBFile(store.databasePath(), options.BackupPath)
|
||||
if err := store.Close(); err != nil {
|
||||
return options.BackupPath, fmt.Errorf(
|
||||
"error closing datastore before creating backup: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := store.copyDBFile(dbPath, options.BackupPath); err != nil {
|
||||
return options.BackupPath, err
|
||||
}
|
||||
|
||||
if _, err := store.Open(); err != nil {
|
||||
return options.BackupPath, fmt.Errorf(
|
||||
"error opening datastore after creating backup: %v",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return options.BackupPath, nil
|
||||
}
|
||||
|
||||
// RestoreWithOptions previously saved backup for the current Edition with options
|
||||
|
||||
@@ -103,6 +103,9 @@ func (m *Migrator) Migrate() error {
|
||||
|
||||
// Portainer 2.14
|
||||
newMigration(50, m.migrateDBVersionToDB50),
|
||||
|
||||
// Portainer 2.15
|
||||
newMigration(60, m.migrateDBVersionToDB60),
|
||||
}
|
||||
|
||||
var lastDbVersion int
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package migrator
|
||||
|
||||
import portainer "github.com/portainer/portainer/api"
|
||||
|
||||
func (m *Migrator) migrateDBVersionToDB60() error {
|
||||
if err := m.addGpuInputFieldDB60(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) addGpuInputFieldDB60() error {
|
||||
migrateLog.Info("- add gpu input field")
|
||||
endpoints, err := m.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
endpoint.Gpus = []portainer.Pair{}
|
||||
err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -27,6 +27,9 @@
|
||||
],
|
||||
"endpoints": [
|
||||
{
|
||||
"Agent": {
|
||||
"Version": ""
|
||||
},
|
||||
"AuthorizedTeams": null,
|
||||
"AuthorizedUsers": null,
|
||||
"AzureCredentials": {
|
||||
@@ -35,8 +38,15 @@
|
||||
"TenantID": ""
|
||||
},
|
||||
"ComposeSyntaxMaxVersion": "",
|
||||
"Edge": {
|
||||
"AsyncMode": false,
|
||||
"CommandInterval": 0,
|
||||
"PingInterval": 0,
|
||||
"SnapshotInterval": 0
|
||||
},
|
||||
"EdgeCheckinInterval": 0,
|
||||
"EdgeKey": "",
|
||||
"Gpus": [],
|
||||
"GroupId": 1,
|
||||
"Id": 1,
|
||||
"IsEdgeDevice": false,
|
||||
@@ -169,6 +179,8 @@
|
||||
}
|
||||
},
|
||||
"DockerVersion": "20.10.13",
|
||||
"GpuUseAll": false,
|
||||
"GpuUseList": null,
|
||||
"HealthyContainerCount": 0,
|
||||
"ImageCount": 9,
|
||||
"NodeCount": 0,
|
||||
@@ -682,6 +694,12 @@
|
||||
"BlackListedLabels": [],
|
||||
"DisplayDonationHeader": false,
|
||||
"DisplayExternalContributors": false,
|
||||
"Edge": {
|
||||
"AsyncMode": false,
|
||||
"CommandInterval": 0,
|
||||
"PingInterval": 0,
|
||||
"SnapshotInterval": 0
|
||||
},
|
||||
"EdgeAgentCheckinInterval": 5,
|
||||
"EdgePortainerUrl": "",
|
||||
"EnableEdgeComputeFeatures": false,
|
||||
@@ -778,6 +796,7 @@
|
||||
"IsComposeFormat": false,
|
||||
"Name": "alpine",
|
||||
"Namespace": "",
|
||||
"Option": null,
|
||||
"ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/2",
|
||||
"ResourceControl": null,
|
||||
"Status": 1,
|
||||
@@ -800,6 +819,7 @@
|
||||
"IsComposeFormat": false,
|
||||
"Name": "redis",
|
||||
"Namespace": "",
|
||||
"Option": null,
|
||||
"ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/5",
|
||||
"ResourceControl": null,
|
||||
"Status": 1,
|
||||
@@ -822,6 +842,7 @@
|
||||
"IsComposeFormat": false,
|
||||
"Name": "nginx",
|
||||
"Namespace": "",
|
||||
"Option": null,
|
||||
"ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/6",
|
||||
"ResourceControl": null,
|
||||
"Status": 1,
|
||||
@@ -898,7 +919,7 @@
|
||||
],
|
||||
"version": {
|
||||
"DB_UPDATING": "false",
|
||||
"DB_VERSION": "50",
|
||||
"DB_VERSION": "61",
|
||||
"INSTANCE_ID": "null"
|
||||
}
|
||||
}
|
||||
+34
-1
@@ -7,9 +7,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
_container "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// Snapshotter represents a service used to create environment(endpoint) snapshots
|
||||
@@ -154,11 +155,35 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
|
||||
healthyContainers := 0
|
||||
unhealthyContainers := 0
|
||||
stacks := make(map[string]struct{})
|
||||
gpuUseSet := make(map[string]struct{})
|
||||
gpuUseAll := false
|
||||
for _, container := range containers {
|
||||
if container.State == "exited" {
|
||||
stoppedContainers++
|
||||
} else if container.State == "running" {
|
||||
runningContainers++
|
||||
|
||||
// snapshot GPUs
|
||||
response, err := cli.ContainerInspect(context.Background(), container.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var gpuOptions *_container.DeviceRequest = nil
|
||||
for _, deviceRequest := range response.HostConfig.Resources.DeviceRequests {
|
||||
if deviceRequest.Driver == "nvidia" || deviceRequest.Capabilities[0][0] == "gpu" {
|
||||
gpuOptions = &deviceRequest
|
||||
}
|
||||
}
|
||||
|
||||
if gpuOptions != nil {
|
||||
if gpuOptions.Count == -1 {
|
||||
gpuUseAll = true
|
||||
}
|
||||
for _, id := range gpuOptions.DeviceIDs {
|
||||
gpuUseSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(container.Status, "(healthy)") {
|
||||
@@ -174,6 +199,14 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
|
||||
}
|
||||
}
|
||||
|
||||
gpuUseList := make([]string, 0, len(gpuUseSet))
|
||||
for gpuUse := range gpuUseSet {
|
||||
gpuUseList = append(gpuUseList, gpuUse)
|
||||
}
|
||||
|
||||
snapshot.GpuUseAll = gpuUseAll
|
||||
snapshot.GpuUseList = gpuUseList
|
||||
|
||||
snapshot.RunningContainerCount = runningContainers
|
||||
snapshot.StoppedContainerCount = stoppedContainers
|
||||
snapshot.HealthyContainerCount = healthyContainers
|
||||
|
||||
+43
-180
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
libstack "github.com/portainer/docker-compose-wrapper"
|
||||
"github.com/portainer/docker-compose-wrapper/compose"
|
||||
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory"
|
||||
@@ -56,13 +54,13 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
envFilePath, err := createEnvFile(stack)
|
||||
envFile, err := createEnvFile(stack)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create env file")
|
||||
}
|
||||
|
||||
filePaths := stackutils.GetStackFilePaths(stack)
|
||||
err = manager.deployer.Deploy(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFilePath, forceRereate)
|
||||
err = manager.deployer.Deploy(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile, forceRereate)
|
||||
return errors.Wrap(err, "failed to deploy a stack")
|
||||
}
|
||||
|
||||
@@ -76,15 +74,38 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
if err := updateNetworkEnvFile(stack); err != nil {
|
||||
return err
|
||||
envFile, err := createEnvFile(stack)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create env file")
|
||||
}
|
||||
|
||||
filePaths := stackutils.GetStackFilePaths(stack)
|
||||
err = manager.deployer.Remove(ctx, stack.ProjectPath, url, stack.Name, filePaths)
|
||||
|
||||
err = manager.deployer.Remove(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile)
|
||||
return errors.Wrap(err, "failed to remove a stack")
|
||||
}
|
||||
|
||||
// Pull an image associated with a service defined in a docker-compose.yml or docker-stack.yml file,
|
||||
// but does not start containers based on those images.
|
||||
func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
url, proxy, err := manager.fetchEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if proxy != nil {
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
envFile, err := createEnvFile(stack)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create env file")
|
||||
}
|
||||
|
||||
filePaths := stackutils.GetStackFilePaths(stack)
|
||||
err = manager.deployer.Pull(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile)
|
||||
return errors.Wrap(err, "failed to pull images of the stack")
|
||||
}
|
||||
|
||||
// NormalizeStackName returns a new stack name with unsupported characters replaced
|
||||
func (manager *ComposeStackManager) NormalizeStackName(name string) string {
|
||||
return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "")
|
||||
@@ -103,200 +124,42 @@ func (manager *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpo
|
||||
return fmt.Sprintf("tcp://127.0.0.1:%d", proxy.Port), proxy, nil
|
||||
}
|
||||
|
||||
// createEnvFile creates a file that would hold both "in-place" and default environment variables.
|
||||
// It will return the name of the file if the stack has "in-place" env vars, otherwise empty string.
|
||||
func createEnvFile(stack *portainer.Stack) (string, error) {
|
||||
// workaround for EE-1862. It will have to be removed when
|
||||
// docker/compose upgraded to v2.x.
|
||||
if err := createNetworkEnvFile(stack); err != nil {
|
||||
return "", errors.Wrap(err, "failed to create network env file")
|
||||
}
|
||||
|
||||
if stack.Env == nil || len(stack.Env) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
envFilePath := path.Join(stack.ProjectPath, "stack.env")
|
||||
|
||||
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer envfile.Close()
|
||||
|
||||
copyDefaultEnvFile(stack, envfile)
|
||||
|
||||
for _, v := range stack.Env {
|
||||
envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value))
|
||||
}
|
||||
envfile.Close()
|
||||
|
||||
return "stack.env", nil
|
||||
}
|
||||
|
||||
func fileNotExist(filePath string) bool {
|
||||
if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func updateNetworkEnvFile(stack *portainer.Stack) error {
|
||||
envFilePath := path.Join(stack.ProjectPath, ".env")
|
||||
stackFilePath := path.Join(stack.ProjectPath, "stack.env")
|
||||
if fileNotExist(envFilePath) {
|
||||
if fileNotExist(stackFilePath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
flags := os.O_WRONLY | os.O_SYNC | os.O_CREATE
|
||||
envFile, err := os.OpenFile(envFilePath, flags, 0666)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer envFile.Close()
|
||||
|
||||
stackFile, err := os.Open(stackFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer stackFile.Close()
|
||||
|
||||
_, err = io.Copy(envFile, stackFile)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createNetworkEnvFile(stack *portainer.Stack) error {
|
||||
networkNameSet := NewStringSet()
|
||||
|
||||
for _, filePath := range stackutils.GetStackFilePaths(stack) {
|
||||
networkNames, err := extractNetworkNames(filePath)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to extract network name")
|
||||
}
|
||||
|
||||
if networkNames == nil || networkNames.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
networkNameSet.Union(networkNames)
|
||||
}
|
||||
|
||||
for _, s := range networkNameSet.List() {
|
||||
if _, ok := os.LookupEnv(s); ok {
|
||||
networkNameSet.Remove(s)
|
||||
}
|
||||
}
|
||||
|
||||
if networkNameSet.Len() == 0 && stack.Env == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
envfile, err := os.OpenFile(path.Join(stack.ProjectPath, ".env"),
|
||||
os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
// copyDefaultEnvFile copies the default .env file if it exists to the provided writer
|
||||
func copyDefaultEnvFile(stack *portainer.Stack, w io.Writer) {
|
||||
defaultEnvFile, err := os.Open(path.Join(path.Join(stack.ProjectPath, path.Dir(stack.EntryPoint)), ".env"))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to open env file")
|
||||
// If cannot open a default file, then don't need to copy it.
|
||||
// We could as well stat it and check if it exists, but this is more efficient.
|
||||
return
|
||||
}
|
||||
|
||||
defer envfile.Close()
|
||||
defer defaultEnvFile.Close()
|
||||
|
||||
var scanEnvSettingFunc = func(name string) (string, bool) {
|
||||
if stack.Env != nil {
|
||||
for _, v := range stack.Env {
|
||||
if name == v.Name {
|
||||
return v.Value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
if _, err = io.Copy(w, defaultEnvFile); err == nil {
|
||||
io.WriteString(w, "\n")
|
||||
}
|
||||
|
||||
for _, s := range networkNameSet.List() {
|
||||
if _, ok := scanEnvSettingFunc(s); !ok {
|
||||
stack.Env = append(stack.Env, portainer.Pair{
|
||||
Name: s,
|
||||
Value: "None",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if stack.Env != nil {
|
||||
for _, v := range stack.Env {
|
||||
envfile.WriteString(
|
||||
fmt.Sprintf("%s=%s\n", v.Name, v.Value))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractNetworkNames(filePath string) (StringSet, error) {
|
||||
if info, err := os.Stat(filePath); errors.Is(err,
|
||||
os.ErrNotExist) || info.IsDir() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
stackFileContent, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to open yaml file")
|
||||
}
|
||||
|
||||
config, err := loader.ParseYAML(stackFileContent)
|
||||
if err != nil {
|
||||
// invalid stack file
|
||||
return nil, errors.Wrap(err, "invalid stack file")
|
||||
}
|
||||
|
||||
var version string
|
||||
if _, ok := config["version"]; ok {
|
||||
version, _ = config["version"].(string)
|
||||
}
|
||||
|
||||
var networks map[string]interface{}
|
||||
if value, ok := config["networks"]; ok {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if networks, ok = value.(map[string]interface{}); !ok {
|
||||
return nil, nil
|
||||
}
|
||||
} else {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
networkContent, err := loader.LoadNetworks(networks, version)
|
||||
if err != nil {
|
||||
return nil, nil // skip the error
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^\$\{?([^\}]+)\}?$`)
|
||||
networkNames := NewStringSet()
|
||||
|
||||
for _, v := range networkContent {
|
||||
matched := re.FindAllStringSubmatch(v.Name, -1)
|
||||
if matched != nil && matched[0] != nil {
|
||||
if strings.Contains(matched[0][1], ":-") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(matched[0][1], "?") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(matched[0][1], "-") {
|
||||
continue
|
||||
}
|
||||
|
||||
networkNames.Add(matched[0][1])
|
||||
}
|
||||
}
|
||||
|
||||
if networkNames.Len() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return networkNames, nil
|
||||
// If couldn't copy the .env file, then ignore the error and try to continue
|
||||
}
|
||||
|
||||
@@ -65,56 +65,22 @@ func Test_createEnvFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_createNetworkEnvFile(t *testing.T) {
|
||||
func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
buf := []byte(`
|
||||
version: '3.6'
|
||||
services:
|
||||
nginx-example:
|
||||
image: nginx:latest
|
||||
networks:
|
||||
default:
|
||||
name: ${test}
|
||||
driver: bridge
|
||||
`)
|
||||
if err := ioutil.WriteFile(path.Join(dir,
|
||||
"docker-compose.yml"), buf, 0644); err != nil {
|
||||
t.Fatalf("Failed to create yaml file: %s", err)
|
||||
}
|
||||
|
||||
stackWithoutEnv := &portainer.Stack{
|
||||
os.WriteFile(path.Join(dir, ".env"), []byte("VAR1=VAL1\nVAR2=VAL2\n"), 0600)
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Env: []portainer.Pair{},
|
||||
}
|
||||
|
||||
if err := createNetworkEnvFile(stackWithoutEnv); err != nil {
|
||||
t.Fatalf("Failed to create network env file: %s", err)
|
||||
}
|
||||
|
||||
content, err := ioutil.ReadFile(path.Join(dir, ".env"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read network env file: %s", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "test=None\n", string(content))
|
||||
|
||||
stackWithEnv := &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Env: []portainer.Pair{
|
||||
{Name: "test", Value: "test-value"},
|
||||
{Name: "VAR1", Value: "NEW_VAL1"},
|
||||
{Name: "VAR3", Value: "VAL3"},
|
||||
},
|
||||
}
|
||||
result, err := createEnvFile(stack)
|
||||
assert.Equal(t, "stack.env", result)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, path.Join(dir, "stack.env"))
|
||||
f, _ := os.Open(path.Join(dir, "stack.env"))
|
||||
content, _ := ioutil.ReadAll(f)
|
||||
|
||||
if err := createNetworkEnvFile(stackWithEnv); err != nil {
|
||||
t.Fatalf("Failed to create network env file: %s", err)
|
||||
}
|
||||
|
||||
content, err = ioutil.ReadFile(path.Join(dir, ".env"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read network env file: %s", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "test=test-value\n", string(content))
|
||||
assert.Equal(t, []byte("VAR1=VAL1\nVAR2=VAL2\n\nVAR1=NEW_VAL1\nVAR3=VAL3\n"), content)
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
|
||||
}
|
||||
|
||||
// Deploy executes the docker stack deploy command.
|
||||
func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error {
|
||||
func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pullImage bool, endpoint *portainer.Endpoint) error {
|
||||
filePaths := stackutils.GetStackFilePaths(stack)
|
||||
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
|
||||
if err != nil {
|
||||
@@ -101,6 +101,9 @@ func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, end
|
||||
} else {
|
||||
args = append(args, "stack", "deploy", "--with-registry-auth")
|
||||
}
|
||||
if !pullImage {
|
||||
args = append(args, "--resolve-image=never")
|
||||
}
|
||||
|
||||
args = configureFilePaths(args, filePaths)
|
||||
args = append(args, stack.Name)
|
||||
|
||||
+10
-8
@@ -1,6 +1,6 @@
|
||||
module github.com/portainer/portainer/api
|
||||
|
||||
go 1.17
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.5.1
|
||||
@@ -11,7 +11,7 @@ require (
|
||||
github.com/coreos/go-semver v0.3.0
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9
|
||||
github.com/docker/cli v20.10.9+incompatible
|
||||
github.com/docker/docker v20.10.9+incompatible
|
||||
github.com/docker/docker v20.10.16+incompatible
|
||||
github.com/fvbommel/sortorder v1.0.2
|
||||
github.com/fxamacker/cbor/v2 v2.3.0
|
||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
|
||||
@@ -20,7 +20,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.10.1
|
||||
github.com/gofrs/uuid v4.0.0+incompatible
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||
github.com/google/go-cmp v0.5.6
|
||||
github.com/google/go-cmp v0.5.8
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.7.3
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
@@ -32,8 +32,8 @@ require (
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
|
||||
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220708023447-a69a4ebaa021
|
||||
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a
|
||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
|
||||
github.com/portainer/libhttp v0.0.0-20211208103139-07a5f798eb3f
|
||||
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777
|
||||
@@ -43,6 +43,7 @@ require (
|
||||
github.com/viney-shih/go-lock v1.1.1
|
||||
go.etcd.io/bbolt v1.3.6
|
||||
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3
|
||||
golang.org/x/exp v0.0.0-20220613132600-b0d781184e0d
|
||||
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||
@@ -61,7 +62,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.0.1 // indirect
|
||||
github.com/aws/smithy-go v1.9.0 // indirect
|
||||
github.com/containerd/containerd v1.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/docker/distribution v2.8.0+incompatible // indirect
|
||||
github.com/docker/go-connections v0.4.0 // indirect
|
||||
@@ -95,6 +95,9 @@ require (
|
||||
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/onsi/ginkgo v1.16.4 // indirect
|
||||
github.com/onsi/gomega v1.15.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.0.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
@@ -112,12 +115,11 @@ require (
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
|
||||
google.golang.org/grpc v1.43.0 // indirect
|
||||
google.golang.org/protobuf v1.27.1 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gotest.tools/v3 v3.0.3 // indirect
|
||||
k8s.io/klog/v2 v2.30.0 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20211109043538-20434351676c // indirect
|
||||
k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b // indirect
|
||||
|
||||
+10
-832
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portaineree "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type containerGpusResponse struct {
|
||||
Gpus string `json:"gpus"`
|
||||
}
|
||||
|
||||
// @id dockerContainerGpusInspect
|
||||
// @summary Fetch container gpus data
|
||||
// @description
|
||||
// @description **Access policy**:
|
||||
// @tags docker
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param environmentId path int true "Environment identifier"
|
||||
// @param containerId path int true "Container identifier"
|
||||
// @success 200 {object} containerGpusResponse "Success"
|
||||
// @failure 404 "Environment or container not found"
|
||||
// @failure 400 "Bad request"
|
||||
// @failure 500 "Internal server error"
|
||||
// @router /docker/{environmentId}/containers/{containerId}/gpus [get]
|
||||
func (handler *Handler) containerGpusInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
containerId, err := request.RetrieveRouteVariableValue(r, "containerId")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid container identifier route variable", err)
|
||||
}
|
||||
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
return httperror.NotFound("Unable to find an environment on request context", err)
|
||||
}
|
||||
|
||||
agentTargetHeader := r.Header.Get(portaineree.PortainerAgentTargetHeader)
|
||||
|
||||
cli, err := handler.dockerClientFactory.CreateClient(endpoint, agentTargetHeader, nil)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to connect to the Docker daemon", err)
|
||||
}
|
||||
|
||||
container, err := cli.ContainerInspect(r.Context(), containerId)
|
||||
if err != nil {
|
||||
return httperror.NotFound("Unable to find the container", err)
|
||||
}
|
||||
|
||||
if container.HostConfig == nil {
|
||||
return httperror.NotFound("Unable to find the container host config", err)
|
||||
}
|
||||
|
||||
gpuOptionsIndex := slices.IndexFunc(container.HostConfig.DeviceRequests, func(opt containertypes.DeviceRequest) bool {
|
||||
if opt.Driver == "nvidia" {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(opt.Capabilities) == 0 || len(opt.Capabilities[0]) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return opt.Capabilities[0][0] == "gpu"
|
||||
})
|
||||
|
||||
if gpuOptionsIndex == -1 {
|
||||
return response.JSON(w, containerGpusResponse{Gpus: "none"})
|
||||
}
|
||||
|
||||
gpuOptions := container.HostConfig.DeviceRequests[gpuOptionsIndex]
|
||||
|
||||
gpu := "all"
|
||||
if gpuOptions.Count != -1 {
|
||||
gpu = "id:" + strings.Join(gpuOptions.DeviceIDs, ",")
|
||||
}
|
||||
|
||||
return response.JSON(w, containerGpusResponse{Gpus: gpu})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
dockerClientFactory *docker.ClientFactory
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to process non-proxied requests to docker APIs directly.
|
||||
func NewHandler(routePrefix string, bouncer *security.RequestBouncer, dockerClientFactory *docker.ClientFactory) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
|
||||
dockerClientFactory: dockerClientFactory,
|
||||
}
|
||||
|
||||
router := h.PathPrefix(routePrefix).Subrouter()
|
||||
router.Use(bouncer.AuthenticatedAccess)
|
||||
|
||||
router.Handle("/{containerId}/gpus", httperror.LoggerHandler(h.containerGpusInspect)).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/handler/docker/containers"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler which will natively deal with to external environments(endpoints).
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
requestBouncer *security.RequestBouncer
|
||||
dataStore dataservices.DataStore
|
||||
dockerClientFactory *docker.ClientFactory
|
||||
authorizationService *authorization.Service
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to process non-proxied requests to docker APIs directly.
|
||||
func NewHandler(bouncer *security.RequestBouncer, authorizationService *authorization.Service, dataStore dataservices.DataStore, dockerClientFactory *docker.ClientFactory) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
authorizationService: authorizationService,
|
||||
dataStore: dataStore,
|
||||
dockerClientFactory: dockerClientFactory,
|
||||
}
|
||||
|
||||
// endpoints
|
||||
endpointRouter := h.PathPrefix("/{id}").Subrouter()
|
||||
endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
|
||||
endpointRouter.Use(dockerOnlyMiddleware)
|
||||
|
||||
containersHandler := containers.NewHandler("/{id}/containers", bouncer, dockerClientFactory)
|
||||
endpointRouter.PathPrefix("/containers").Handler(containersHandler)
|
||||
return h
|
||||
}
|
||||
|
||||
func dockerOnlyMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
|
||||
endpoint, err := middlewares.FetchEndpoint(request)
|
||||
if err != nil {
|
||||
httperror.WriteError(rw, http.StatusInternalServerError, "Unable to find an environment on request context", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !endpointutils.IsDockerEndpoint(endpoint) {
|
||||
errMessage := "environment is not a docker environment"
|
||||
httperror.WriteError(rw, http.StatusBadRequest, errMessage, errors.New(errMessage))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(rw, request)
|
||||
})
|
||||
}
|
||||
@@ -77,14 +77,17 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
|
||||
if endpoint.EdgeID == "" {
|
||||
edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader)
|
||||
endpoint.EdgeID = edgeIdentifier
|
||||
|
||||
agentPlatform, agentPlatformErr := parseAgentPlatform(r)
|
||||
if agentPlatformErr != nil {
|
||||
return httperror.BadRequest("agent platform header is not valid", err)
|
||||
}
|
||||
endpoint.Type = agentPlatform
|
||||
}
|
||||
|
||||
agentPlatform, agentPlatformErr := parseAgentPlatform(r)
|
||||
if agentPlatformErr != nil {
|
||||
return httperror.BadRequest("agent platform header is not valid", err)
|
||||
}
|
||||
endpoint.Type = agentPlatform
|
||||
|
||||
version := r.Header.Get(portainer.PortainerAgentHeader)
|
||||
endpoint.Agent.Version = version
|
||||
|
||||
endpoint.LastCheckInDate = time.Now().Unix()
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||
|
||||
@@ -57,7 +57,7 @@ var endpointTestCases = []endpointTestCase{
|
||||
portainer.EndpointRelation{
|
||||
EndpointID: 2,
|
||||
},
|
||||
http.StatusBadRequest,
|
||||
http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
portainer.Endpoint{
|
||||
@@ -194,7 +194,9 @@ func TestWithEndpoints(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id")
|
||||
|
||||
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, test.endpoint.EdgeID)
|
||||
req.Header.Set(portainer.HTTPResponseAgentPlatform, "1")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
@@ -239,6 +241,7 @@ func TestLastCheckInDateIncreases(t *testing.T) {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id")
|
||||
req.Header.Set(portainer.HTTPResponseAgentPlatform, "1")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
@@ -355,6 +358,7 @@ func TestEdgeStackStatus(t *testing.T) {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id")
|
||||
req.Header.Set(portainer.HTTPResponseAgentPlatform, "1")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
@@ -424,6 +428,7 @@ func TestEdgeJobsResponse(t *testing.T) {
|
||||
t.Fatal("request error:", err)
|
||||
}
|
||||
req.Header.Set(portainer.PortainerAgentEdgeIDHeader, "edge-id")
|
||||
req.Header.Set(portainer.HTTPResponseAgentPlatform, "1")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/set"
|
||||
)
|
||||
|
||||
// @id AgentVersions
|
||||
// @summary List agent versions
|
||||
// @description List all agent versions based on the current user authorizations and query parameters.
|
||||
// @description **Access policy**: restricted
|
||||
// @tags endpoints
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @success 200 {array} string "List of available agent versions"
|
||||
// @failure 500 "Server error"
|
||||
// @router /endpoints/agent_versions [get]
|
||||
|
||||
func (handler *Handler) agentVersions(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
|
||||
}
|
||||
|
||||
endpoints, err := handler.DataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve environments from the database", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
|
||||
|
||||
agentVersions := set.Set[string]{}
|
||||
for _, endpoint := range filteredEndpoints {
|
||||
if endpoint.Agent.Version != "" {
|
||||
agentVersions[endpoint.Agent.Version] = true
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, agentVersions.Keys())
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
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/agent"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
@@ -25,6 +24,7 @@ type endpointCreatePayload struct {
|
||||
URL string
|
||||
EndpointCreationType endpointCreationEnum
|
||||
PublicURL string
|
||||
Gpus []portainer.Pair
|
||||
GroupID int
|
||||
TLS bool
|
||||
TLSSkipVerify bool
|
||||
@@ -142,6 +142,13 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||
payload.PublicURL = publicURL
|
||||
}
|
||||
|
||||
gpus := make([]portainer.Pair, 0)
|
||||
err = request.RetrieveMultiPartFormJSONValue(r, "Gpus", &gpus, true)
|
||||
if err != nil {
|
||||
return errors.New("Invalid Gpus parameter")
|
||||
}
|
||||
payload.Gpus = gpus
|
||||
|
||||
checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true)
|
||||
payload.EdgeCheckinInterval = checkinInterval
|
||||
|
||||
@@ -237,6 +244,7 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
|
||||
func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) {
|
||||
var err error
|
||||
switch payload.EndpointCreationType {
|
||||
case azureEnvironment:
|
||||
return handler.createAzureEndpoint(payload)
|
||||
@@ -249,12 +257,22 @@ func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portain
|
||||
}
|
||||
|
||||
endpointType := portainer.DockerEnvironment
|
||||
var agentVersion string
|
||||
if payload.EndpointCreationType == agentEnvironment {
|
||||
agentPlatform, err := handler.pingAndCheckPlatform(payload)
|
||||
var tlsConfig *tls.Config
|
||||
if payload.TLS {
|
||||
tlsConfig, err = crypto.CreateTLSConfigurationFromBytes(payload.TLSCACertFile, payload.TLSCertFile, payload.TLSKeyFile, payload.TLSSkipVerify, payload.TLSSkipClientVerify)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to create TLS configuration", err)
|
||||
}
|
||||
}
|
||||
|
||||
agentPlatform, version, err := agent.GetAgentVersionAndPlatform(payload.URL, tlsConfig)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to get environment type", err}
|
||||
}
|
||||
|
||||
agentVersion = version
|
||||
if agentPlatform == portainer.AgentPlatformDocker {
|
||||
endpointType = portainer.AgentOnDockerEnvironment
|
||||
} else if agentPlatform == portainer.AgentPlatformKubernetes {
|
||||
@@ -264,7 +282,7 @@ func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portain
|
||||
}
|
||||
|
||||
if payload.TLS {
|
||||
return handler.createTLSSecuredEndpoint(payload, endpointType)
|
||||
return handler.createTLSSecuredEndpoint(payload, endpointType, agentVersion)
|
||||
}
|
||||
return handler.createUnsecuredEndpoint(payload)
|
||||
}
|
||||
@@ -290,6 +308,7 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po
|
||||
Type: portainer.AzureEnvironment,
|
||||
GroupID: portainer.EndpointGroupID(payload.GroupID),
|
||||
PublicURL: payload.PublicURL,
|
||||
Gpus: payload.Gpus,
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
AzureCredentials: credentials,
|
||||
@@ -323,6 +342,7 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
|
||||
URL: portainerHost,
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
GroupID: portainer.EndpointGroupID(payload.GroupID),
|
||||
Gpus: payload.Gpus,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: false,
|
||||
},
|
||||
@@ -378,6 +398,7 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
|
||||
Type: endpointType,
|
||||
GroupID: portainer.EndpointGroupID(payload.GroupID),
|
||||
PublicURL: payload.PublicURL,
|
||||
Gpus: payload.Gpus,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: false,
|
||||
},
|
||||
@@ -412,6 +433,7 @@ func (handler *Handler) createKubernetesEndpoint(payload *endpointCreatePayload)
|
||||
Type: portainer.KubernetesLocalEnvironment,
|
||||
GroupID: portainer.EndpointGroupID(payload.GroupID),
|
||||
PublicURL: payload.PublicURL,
|
||||
Gpus: payload.Gpus,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: payload.TLS,
|
||||
TLSSkipVerify: payload.TLSSkipVerify,
|
||||
@@ -432,7 +454,7 @@ func (handler *Handler) createKubernetesEndpoint(payload *endpointCreatePayload)
|
||||
return endpoint, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload, endpointType portainer.EndpointType) (*portainer.Endpoint, *httperror.HandlerError) {
|
||||
func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload, endpointType portainer.EndpointType, agentVersion string) (*portainer.Endpoint, *httperror.HandlerError) {
|
||||
endpointID := handler.DataStore.Endpoint().GetNextIdentifier()
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: portainer.EndpointID(endpointID),
|
||||
@@ -441,6 +463,7 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload,
|
||||
Type: endpointType,
|
||||
GroupID: portainer.EndpointGroupID(payload.GroupID),
|
||||
PublicURL: payload.PublicURL,
|
||||
Gpus: payload.Gpus,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: payload.TLS,
|
||||
TLSSkipVerify: payload.TLSSkipVerify,
|
||||
@@ -454,6 +477,8 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload,
|
||||
IsEdgeDevice: payload.IsEdgeDevice,
|
||||
}
|
||||
|
||||
endpoint.Agent.Version = agentVersion
|
||||
|
||||
err := handler.storeTLSFiles(endpoint, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -547,58 +572,3 @@ func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *end
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) pingAndCheckPlatform(payload *endpointCreatePayload) (portainer.AgentPlatform, error) {
|
||||
httpCli := &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
}
|
||||
|
||||
if payload.TLS {
|
||||
tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.TLSCACertFile, payload.TLSCertFile, payload.TLSKeyFile, payload.TLSSkipVerify, payload.TLSSkipClientVerify)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
httpCli.Transport = &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
}
|
||||
}
|
||||
|
||||
url, err := url.Parse(fmt.Sprintf("%s/ping", payload.URL))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
url.Scheme = "https"
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, url.String(), nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
resp, err := httpCli.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return 0, fmt.Errorf("Failed request with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
agentPlatformHeader := resp.Header.Get(portainer.HTTPResponseAgentPlatform)
|
||||
if agentPlatformHeader == "" {
|
||||
return 0, errors.New("Agent Platform Header is missing")
|
||||
}
|
||||
|
||||
agentPlatformNumber, err := strconv.Atoi(agentPlatformHeader)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if agentPlatformNumber == 0 {
|
||||
return 0, errors.New("Agent platform is invalid")
|
||||
}
|
||||
|
||||
return portainer.AgentPlatform(agentPlatformNumber), nil
|
||||
}
|
||||
|
||||
@@ -4,24 +4,14 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
EdgeDeviceFilterAll = "all"
|
||||
EdgeDeviceFilterTrusted = "trusted"
|
||||
EdgeDeviceFilterUntrusted = "untrusted"
|
||||
EdgeDeviceFilterNone = "none"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -29,8 +19,6 @@ const (
|
||||
EdgeDeviceIntervalAdd = 20
|
||||
)
|
||||
|
||||
var endpointGroupNames map[portainer.EndpointGroupID]string
|
||||
|
||||
// @id EndpointList
|
||||
// @summary List environments(endpoints)
|
||||
// @description List all environments(endpoints) based on the current user authorizations. Will
|
||||
@@ -42,14 +30,20 @@ var endpointGroupNames map[portainer.EndpointGroupID]string
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param start query int false "Start searching from"
|
||||
// @param search query string false "Search query"
|
||||
// @param groupId query int false "List environments(endpoints) of this group"
|
||||
// @param limit query int false "Limit results to this value"
|
||||
// @param sort query int false "Sort results by this value"
|
||||
// @param order query int false "Order sorted results by desc/asc" Enum("asc", "desc")
|
||||
// @param search query string false "Search query"
|
||||
// @param groupIds query []int false "List environments(endpoints) of these groups"
|
||||
// @param status query []int false "List environments(endpoints) by this status"
|
||||
// @param types query []int false "List environments(endpoints) of this type"
|
||||
// @param tagIds query []int false "search environments(endpoints) with these tags (depends on tagsPartialMatch)"
|
||||
// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags"
|
||||
// @param endpointIds query []int false "will return only these environments(endpoints)"
|
||||
// @param edgeDeviceFilter query string false "will return only these edge environments, none will return only regular edge environments" Enum("all", "trusted", "untrusted", "none")
|
||||
// @param provisioned query bool false "If true, will return environment(endpoint) that were provisioned"
|
||||
// @param agentVersions query []string false "will return only environments with on of these agent versions"
|
||||
// @param edgeDevice query bool false "if exists true show only edge devices, false show only regular edge endpoints. if missing, will show both types (relevant only for edge endpoints)"
|
||||
// @param edgeDeviceUntrusted query bool false "if true, show only untrusted endpoints, if false show only trusted (relevant only for edge devices, and if edgeDevice is true)"
|
||||
// @param name query string false "will return only environments(endpoints) with this name"
|
||||
// @success 200 {array} portainer.Endpoint "Endpoints"
|
||||
// @failure 500 "Server error"
|
||||
@@ -60,103 +54,42 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
start--
|
||||
}
|
||||
|
||||
search, _ := request.RetrieveQueryParameter(r, "search", true)
|
||||
if search != "" {
|
||||
search = strings.ToLower(search)
|
||||
}
|
||||
|
||||
groupID, _ := request.RetrieveNumericQueryParameter(r, "groupId", true)
|
||||
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
|
||||
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
|
||||
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
|
||||
|
||||
var endpointTypes []int
|
||||
request.RetrieveJSONQueryParameter(r, "types", &endpointTypes, true)
|
||||
|
||||
var tagIDs []portainer.TagID
|
||||
request.RetrieveJSONQueryParameter(r, "tagIds", &tagIDs, true)
|
||||
|
||||
tagsPartialMatch, _ := request.RetrieveBooleanQueryParameter(r, "tagsPartialMatch", true)
|
||||
|
||||
var endpointIDs []portainer.EndpointID
|
||||
request.RetrieveJSONQueryParameter(r, "endpointIds", &endpointIDs, true)
|
||||
|
||||
var statuses []int
|
||||
request.RetrieveJSONQueryParameter(r, "status", &statuses, true)
|
||||
|
||||
var groupIDs []int
|
||||
request.RetrieveJSONQueryParameter(r, "groupIds", &groupIDs, true)
|
||||
|
||||
endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from the database", err}
|
||||
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
|
||||
}
|
||||
|
||||
endpoints, err := handler.DataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err}
|
||||
return httperror.InternalServerError("Unable to retrieve environments from the database", err)
|
||||
}
|
||||
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
return httperror.InternalServerError("Unable to retrieve info from request context", err)
|
||||
}
|
||||
|
||||
query, err := parseQuery(r)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid query parameters", err)
|
||||
}
|
||||
|
||||
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
|
||||
totalAvailableEndpoints := len(filteredEndpoints)
|
||||
|
||||
if groupID != 0 {
|
||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, []int{groupID})
|
||||
filteredEndpoints, totalAvailableEndpoints, err := handler.filterEndpointsByQuery(filteredEndpoints, query, endpointGroups, settings)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to filter endpoints", err)
|
||||
}
|
||||
|
||||
if endpointIDs != nil {
|
||||
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs)
|
||||
}
|
||||
|
||||
if len(groupIDs) > 0 {
|
||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, groupIDs)
|
||||
}
|
||||
|
||||
name, _ := request.RetrieveQueryParameter(r, "name", true)
|
||||
if name != "" {
|
||||
filteredEndpoints = filterEndpointsByName(filteredEndpoints, name)
|
||||
}
|
||||
|
||||
edgeDeviceFilter, _ := request.RetrieveQueryParameter(r, "edgeDeviceFilter", false)
|
||||
if edgeDeviceFilter != "" {
|
||||
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter)
|
||||
}
|
||||
|
||||
if len(statuses) > 0 {
|
||||
filteredEndpoints = filterEndpointsByStatuses(filteredEndpoints, statuses, settings)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
tags, err := handler.DataStore.Tag().Tags()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err}
|
||||
}
|
||||
tagsMap := make(map[portainer.TagID]string)
|
||||
for _, tag := range tags {
|
||||
tagsMap[tag.ID] = tag.Name
|
||||
}
|
||||
filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, endpointGroups, tagsMap, search)
|
||||
}
|
||||
|
||||
if endpointTypes != nil {
|
||||
filteredEndpoints = filterEndpointsByTypes(filteredEndpoints, endpointTypes)
|
||||
}
|
||||
|
||||
if tagIDs != nil {
|
||||
filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, tagIDs, endpointGroups, tagsPartialMatch)
|
||||
}
|
||||
|
||||
// Sort endpoints by field
|
||||
sortEndpointsByField(filteredEndpoints, endpointGroups, sortField, sortOrder == "desc")
|
||||
|
||||
filteredEndpointCount := len(filteredEndpoints)
|
||||
@@ -196,64 +129,6 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
|
||||
return endpoints[start:end]
|
||||
}
|
||||
|
||||
func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs []int) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if utils.Contains(endpointGroupIDs, int(endpoint.GroupID)) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
endpointTags := convertTagIDsToTags(tagsMap, endpoint.TagIDs)
|
||||
if endpointMatchSearchCriteria(&endpoint, endpointTags, searchCriteria) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
continue
|
||||
}
|
||||
|
||||
if endpointGroupMatchSearchCriteria(&endpoint, endpointGroups, tagsMap, searchCriteria) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []int, settings *portainer.Settings) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
status := endpoint.Status
|
||||
if endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||
isCheckValid := false
|
||||
edgeCheckinInterval := endpoint.EdgeCheckinInterval
|
||||
if endpoint.EdgeCheckinInterval == 0 {
|
||||
edgeCheckinInterval = settings.EdgeAgentCheckinInterval
|
||||
}
|
||||
if edgeCheckinInterval != 0 && endpoint.LastCheckInDate != 0 {
|
||||
isCheckValid = time.Now().Unix()-endpoint.LastCheckInDate <= int64(edgeCheckinInterval*EdgeDeviceIntervalMultiplier+EdgeDeviceIntervalAdd)
|
||||
}
|
||||
status = portainer.EndpointStatusDown // Offline
|
||||
if isCheckValid {
|
||||
status = portainer.EndpointStatusUp // Online
|
||||
}
|
||||
}
|
||||
|
||||
if utils.Contains(statuses, int(status)) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func sortEndpointsByField(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, sortField string, isSortDesc bool) {
|
||||
|
||||
switch sortField {
|
||||
@@ -265,7 +140,7 @@ func sortEndpointsByField(endpoints []portainer.Endpoint, endpointGroups []porta
|
||||
}
|
||||
|
||||
case "Group":
|
||||
endpointGroupNames = make(map[portainer.EndpointGroupID]string, 0)
|
||||
endpointGroupNames := make(map[portainer.EndpointGroupID]string, 0)
|
||||
for _, group := range endpointGroups {
|
||||
endpointGroupNames[group.ID] = group.Name
|
||||
}
|
||||
@@ -294,123 +169,6 @@ func sortEndpointsByField(endpoints []portainer.Endpoint, endpointGroups []porta
|
||||
}
|
||||
}
|
||||
|
||||
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, searchCriteria string) bool {
|
||||
if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(endpoint.URL), searchCriteria) {
|
||||
return true
|
||||
}
|
||||
|
||||
if endpoint.Status == portainer.EndpointStatusUp && searchCriteria == "up" {
|
||||
return true
|
||||
} else if endpoint.Status == portainer.EndpointStatusDown && searchCriteria == "down" {
|
||||
return true
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if strings.Contains(strings.ToLower(tag), searchCriteria) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) bool {
|
||||
for _, group := range endpointGroups {
|
||||
if group.ID == endpoint.GroupID {
|
||||
if strings.Contains(strings.ToLower(group.Name), searchCriteria) {
|
||||
return true
|
||||
}
|
||||
tags := convertTagIDsToTags(tagsMap, group.TagIDs)
|
||||
for _, tag := range tags {
|
||||
if strings.Contains(strings.ToLower(tag), searchCriteria) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
typeSet := map[portainer.EndpointType]bool{}
|
||||
for _, endpointType := range endpointTypes {
|
||||
typeSet[portainer.EndpointType(endpointType)] = true
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if typeSet[endpoint.Type] {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDeviceFilter string) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if shouldReturnEdgeDevice(endpoint, edgeDeviceFilter) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func shouldReturnEdgeDevice(endpoint portainer.Endpoint, edgeDeviceFilter string) bool {
|
||||
// none - return all endpoints that are not edge devices
|
||||
if edgeDeviceFilter == EdgeDeviceFilterNone && !endpoint.IsEdgeDevice {
|
||||
return true
|
||||
}
|
||||
|
||||
if !endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch edgeDeviceFilter {
|
||||
case EdgeDeviceFilterAll:
|
||||
return true
|
||||
case EdgeDeviceFilterTrusted:
|
||||
return endpoint.UserTrusted
|
||||
case EdgeDeviceFilterUntrusted:
|
||||
return !endpoint.UserTrusted
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
|
||||
tags := make([]string, 0)
|
||||
for _, tagID := range tagIDs {
|
||||
tags = append(tags, tagsMap[tagID])
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup, partialMatch bool) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
endpointGroup := getEndpointGroup(endpoint.GroupID, endpointGroups)
|
||||
endpointMatched := false
|
||||
if partialMatch {
|
||||
endpointMatched = endpointPartialMatchTags(endpoint, endpointGroup, tagIDs)
|
||||
} else {
|
||||
endpointMatched = endpointFullMatchTags(endpoint, endpointGroup, tagIDs)
|
||||
}
|
||||
|
||||
if endpointMatched {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup) portainer.EndpointGroup {
|
||||
var endpointGroup portainer.EndpointGroup
|
||||
for _, group := range groups {
|
||||
@@ -421,72 +179,3 @@ func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.Endp
|
||||
}
|
||||
return endpointGroup
|
||||
}
|
||||
|
||||
func endpointPartialMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool {
|
||||
tagSet := make(map[portainer.TagID]bool)
|
||||
for _, tagID := range tagIDs {
|
||||
tagSet[tagID] = true
|
||||
}
|
||||
for _, tagID := range endpoint.TagIDs {
|
||||
if tagSet[tagID] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, tagID := range endpointGroup.TagIDs {
|
||||
if tagSet[tagID] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func endpointFullMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool {
|
||||
missingTags := make(map[portainer.TagID]bool)
|
||||
for _, tagID := range tagIDs {
|
||||
missingTags[tagID] = true
|
||||
}
|
||||
for _, tagID := range endpoint.TagIDs {
|
||||
if missingTags[tagID] {
|
||||
delete(missingTags, tagID)
|
||||
}
|
||||
}
|
||||
for _, tagID := range endpointGroup.TagIDs {
|
||||
if missingTags[tagID] {
|
||||
delete(missingTags, tagID)
|
||||
}
|
||||
}
|
||||
return len(missingTags) == 0
|
||||
}
|
||||
|
||||
func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.EndpointID) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
idsSet := make(map[portainer.EndpointID]bool)
|
||||
for _, id := range ids {
|
||||
idsSet[id] = true
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if idsSet[endpoint.ID] {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredEndpoints
|
||||
|
||||
}
|
||||
|
||||
func filterEndpointsByName(endpoints []portainer.Endpoint, name string) []portainer.Endpoint {
|
||||
if name == "" {
|
||||
return endpoints
|
||||
}
|
||||
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.Name == name {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
@@ -16,66 +16,147 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type endpointListEdgeDeviceTest struct {
|
||||
type endpointListTest struct {
|
||||
title string
|
||||
expected []portainer.EndpointID
|
||||
filter string
|
||||
}
|
||||
|
||||
func Test_endpointList(t *testing.T) {
|
||||
var err error
|
||||
is := assert.New(t)
|
||||
func Test_EndpointList_AgentVersion(t *testing.T) {
|
||||
|
||||
version1Endpoint := portainer.Endpoint{
|
||||
ID: 1,
|
||||
GroupID: 1,
|
||||
Type: portainer.AgentOnDockerEnvironment,
|
||||
Agent: struct {
|
||||
Version string "example:\"1.0.0\""
|
||||
}{
|
||||
Version: "1.0.0",
|
||||
},
|
||||
}
|
||||
version2Endpoint := portainer.Endpoint{ID: 2, GroupID: 1, Type: portainer.AgentOnDockerEnvironment, Agent: struct {
|
||||
Version string "example:\"1.0.0\""
|
||||
}{Version: "2.0.0"}}
|
||||
noVersionEndpoint := portainer.Endpoint{ID: 3, Type: portainer.AgentOnDockerEnvironment, GroupID: 1}
|
||||
notAgentEnvironments := portainer.Endpoint{ID: 4, Type: portainer.DockerEnvironment, GroupID: 1}
|
||||
|
||||
handler, teardown := setup(t, []portainer.Endpoint{
|
||||
notAgentEnvironments,
|
||||
version1Endpoint,
|
||||
version2Endpoint,
|
||||
noVersionEndpoint,
|
||||
})
|
||||
|
||||
_, store, teardown := datastore.MustNewTestStore(true, true)
|
||||
defer teardown()
|
||||
|
||||
trustedEndpoint := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
untrustedEndpoint := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
type endpointListAgentVersionTest struct {
|
||||
endpointListTest
|
||||
filter []string
|
||||
}
|
||||
|
||||
tests := []endpointListAgentVersionTest{
|
||||
{
|
||||
endpointListTest{
|
||||
"should show version 1 agent endpoints and non-agent endpoints",
|
||||
[]portainer.EndpointID{version1Endpoint.ID, notAgentEnvironments.ID},
|
||||
},
|
||||
[]string{version1Endpoint.Agent.Version},
|
||||
},
|
||||
{
|
||||
endpointListTest{
|
||||
"should show version 2 endpoints and non-agent endpoints",
|
||||
[]portainer.EndpointID{version2Endpoint.ID, notAgentEnvironments.ID},
|
||||
},
|
||||
[]string{version2Endpoint.Agent.Version},
|
||||
},
|
||||
{
|
||||
endpointListTest{
|
||||
"should show version 1 and 2 endpoints and non-agent endpoints",
|
||||
[]portainer.EndpointID{version2Endpoint.ID, notAgentEnvironments.ID, version1Endpoint.ID},
|
||||
},
|
||||
[]string{version2Endpoint.Agent.Version, version1Endpoint.Agent.Version},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
query := ""
|
||||
for _, filter := range test.filter {
|
||||
query += fmt.Sprintf("agentVersions[]=%s&", filter)
|
||||
}
|
||||
|
||||
req := buildEndpointListRequest(query)
|
||||
|
||||
resp, err := doEndpointListRequest(req, handler, is)
|
||||
is.NoError(err)
|
||||
|
||||
is.Equal(len(test.expected), len(resp))
|
||||
|
||||
respIds := []portainer.EndpointID{}
|
||||
|
||||
for _, endpoint := range resp {
|
||||
respIds = append(respIds, endpoint.ID)
|
||||
}
|
||||
|
||||
is.ElementsMatch(test.expected, respIds)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func Test_endpointList_edgeDeviceFilter(t *testing.T) {
|
||||
|
||||
trustedEdgeDevice := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
untrustedEdgeDevice := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
regularUntrustedEdgeEndpoint := portainer.Endpoint{ID: 3, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
regularTrustedEdgeEndpoint := portainer.Endpoint{ID: 4, UserTrusted: true, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
regularEndpoint := portainer.Endpoint{ID: 5, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.DockerEnvironment}
|
||||
|
||||
endpoints := []portainer.Endpoint{
|
||||
trustedEndpoint,
|
||||
untrustedEndpoint,
|
||||
handler, teardown := setup(t, []portainer.Endpoint{
|
||||
trustedEdgeDevice,
|
||||
untrustedEdgeDevice,
|
||||
regularUntrustedEdgeEndpoint,
|
||||
regularTrustedEdgeEndpoint,
|
||||
regularEndpoint,
|
||||
})
|
||||
|
||||
defer teardown()
|
||||
|
||||
type endpointListEdgeDeviceTest struct {
|
||||
endpointListTest
|
||||
edgeDevice *bool
|
||||
edgeDeviceUntrusted bool
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
err = store.Endpoint().Create(&endpoint)
|
||||
is.NoError(err, "error creating environment")
|
||||
}
|
||||
|
||||
err = store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||
is.NoError(err, "error creating a user")
|
||||
|
||||
bouncer := helper.NewTestRequestBouncer()
|
||||
h := NewHandler(bouncer, nil)
|
||||
h.DataStore = store
|
||||
h.ComposeStackManager = testhelpers.NewComposeStackManager()
|
||||
|
||||
tests := []endpointListEdgeDeviceTest{
|
||||
{
|
||||
"should show all edge endpoints",
|
||||
[]portainer.EndpointID{trustedEndpoint.ID, untrustedEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||
EdgeDeviceFilterAll,
|
||||
endpointListTest: endpointListTest{
|
||||
"should show all endpoints except of the untrusted devices",
|
||||
[]portainer.EndpointID{trustedEdgeDevice.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID, regularEndpoint.ID},
|
||||
},
|
||||
edgeDevice: nil,
|
||||
},
|
||||
{
|
||||
"should show only trusted edge devices",
|
||||
[]portainer.EndpointID{trustedEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||
EdgeDeviceFilterTrusted,
|
||||
endpointListTest: endpointListTest{
|
||||
"should show only trusted edge devices and regular endpoints",
|
||||
[]portainer.EndpointID{trustedEdgeDevice.ID, regularEndpoint.ID},
|
||||
},
|
||||
edgeDevice: BoolAddr(true),
|
||||
},
|
||||
{
|
||||
"should show only untrusted edge devices",
|
||||
[]portainer.EndpointID{untrustedEndpoint.ID, regularUntrustedEdgeEndpoint.ID},
|
||||
EdgeDeviceFilterUntrusted,
|
||||
endpointListTest: endpointListTest{
|
||||
"should show only untrusted edge devices and regular endpoints",
|
||||
[]portainer.EndpointID{untrustedEdgeDevice.ID, regularEndpoint.ID},
|
||||
},
|
||||
edgeDevice: BoolAddr(true),
|
||||
edgeDeviceUntrusted: true,
|
||||
},
|
||||
{
|
||||
"should show no edge devices",
|
||||
[]portainer.EndpointID{regularEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||
EdgeDeviceFilterNone,
|
||||
endpointListTest: endpointListTest{
|
||||
"should show no edge devices",
|
||||
[]portainer.EndpointID{regularEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||
},
|
||||
edgeDevice: BoolAddr(false),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -83,8 +164,13 @@ func Test_endpointList(t *testing.T) {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
is := assert.New(t)
|
||||
|
||||
req := buildEndpointListRequest(test.filter)
|
||||
resp, err := doEndpointListRequest(req, h, is)
|
||||
query := fmt.Sprintf("edgeDeviceUntrusted=%v&", test.edgeDeviceUntrusted)
|
||||
if test.edgeDevice != nil {
|
||||
query += fmt.Sprintf("edgeDevice=%v&", *test.edgeDevice)
|
||||
}
|
||||
|
||||
req := buildEndpointListRequest(query)
|
||||
resp, err := doEndpointListRequest(req, handler, is)
|
||||
is.NoError(err)
|
||||
|
||||
is.Equal(len(test.expected), len(resp))
|
||||
@@ -100,8 +186,28 @@ func Test_endpointList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func buildEndpointListRequest(filter string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/endpoints?edgeDeviceFilter=%s", filter), nil)
|
||||
func setup(t *testing.T, endpoints []portainer.Endpoint) (handler *Handler, teardown func()) {
|
||||
is := assert.New(t)
|
||||
_, store, teardown := datastore.MustNewTestStore(true, true)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
err := store.Endpoint().Create(&endpoint)
|
||||
is.NoError(err, "error creating environment")
|
||||
}
|
||||
|
||||
err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||
is.NoError(err, "error creating a user")
|
||||
|
||||
bouncer := helper.NewTestRequestBouncer()
|
||||
handler = NewHandler(bouncer, nil)
|
||||
handler.DataStore = store
|
||||
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
|
||||
|
||||
return handler, teardown
|
||||
}
|
||||
|
||||
func buildEndpointListRequest(query string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/endpoints?%s", query), nil)
|
||||
|
||||
ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1})
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
@@ -55,6 +55,7 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
latestEndpointReference.Snapshots = endpoint.Snapshots
|
||||
latestEndpointReference.Kubernetes.Snapshots = endpoint.Kubernetes.Snapshots
|
||||
latestEndpointReference.Agent.Version = endpoint.Agent.Version
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference)
|
||||
if err != nil {
|
||||
|
||||
@@ -47,6 +47,7 @@ func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request
|
||||
|
||||
latestEndpointReference.Snapshots = endpoint.Snapshots
|
||||
latestEndpointReference.Kubernetes.Snapshots = endpoint.Kubernetes.Snapshots
|
||||
latestEndpointReference.Agent.Version = endpoint.Agent.Version
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,6 +22,8 @@ type endpointUpdatePayload struct {
|
||||
// URL or IP address where exposed containers will be reachable.\
|
||||
// Defaults to URL if not specified
|
||||
PublicURL *string `example:"docker.mydomain.tld:2375"`
|
||||
// GPUs information
|
||||
Gpus []portainer.Pair
|
||||
// Group identifier
|
||||
GroupID *int `example:"1"`
|
||||
// Require TLS to connect against this environment(endpoint)
|
||||
@@ -110,6 +112,10 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
endpoint.PublicURL = *payload.PublicURL
|
||||
}
|
||||
|
||||
if payload.Gpus != nil {
|
||||
endpoint.Gpus = payload.Gpus
|
||||
}
|
||||
|
||||
if payload.EdgeCheckinInterval != nil {
|
||||
endpoint.EdgeCheckinInterval = *payload.EdgeCheckinInterval
|
||||
}
|
||||
@@ -265,7 +271,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
if payload.URL != nil || payload.TLS != nil || endpoint.Type == portainer.AzureEnvironment {
|
||||
if (payload.URL != nil && *payload.URL != endpoint.URL) || (payload.TLS != nil && endpoint.TLSConfig.TLS != *payload.TLS) || endpoint.Type == portainer.AzureEnvironment {
|
||||
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
|
||||
_, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type EnvironmentsQuery struct {
|
||||
search string
|
||||
types []portainer.EndpointType
|
||||
tagIds []portainer.TagID
|
||||
endpointIds []portainer.EndpointID
|
||||
tagsPartialMatch bool
|
||||
groupIds []portainer.EndpointGroupID
|
||||
status []portainer.EndpointStatus
|
||||
edgeDevice *bool
|
||||
edgeDeviceUntrusted bool
|
||||
name string
|
||||
agentVersions []string
|
||||
}
|
||||
|
||||
func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
search, _ := request.RetrieveQueryParameter(r, "search", true)
|
||||
if search != "" {
|
||||
search = strings.ToLower(search)
|
||||
}
|
||||
|
||||
status, err := getNumberArrayQueryParameter[portainer.EndpointStatus](r, "status")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
groupIDs, err := getNumberArrayQueryParameter[portainer.EndpointGroupID](r, "groupIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
endpointTypes, err := getNumberArrayQueryParameter[portainer.EndpointType](r, "types")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
tagIDs, err := getNumberArrayQueryParameter[portainer.TagID](r, "tagIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
tagsPartialMatch, _ := request.RetrieveBooleanQueryParameter(r, "tagsPartialMatch", true)
|
||||
|
||||
endpointIDs, err := getNumberArrayQueryParameter[portainer.EndpointID](r, "endpointIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
agentVersions := getArrayQueryParameter(r, "agentVersions")
|
||||
|
||||
name, _ := request.RetrieveQueryParameter(r, "name", true)
|
||||
|
||||
edgeDeviceParam, _ := request.RetrieveQueryParameter(r, "edgeDevice", true)
|
||||
|
||||
var edgeDevice *bool
|
||||
if edgeDeviceParam != "" {
|
||||
edgeDevice = BoolAddr(edgeDeviceParam == "true")
|
||||
}
|
||||
|
||||
edgeDeviceUntrusted, _ := request.RetrieveBooleanQueryParameter(r, "edgeDeviceUntrusted", true)
|
||||
|
||||
return EnvironmentsQuery{
|
||||
search: search,
|
||||
types: endpointTypes,
|
||||
tagIds: tagIDs,
|
||||
endpointIds: endpointIDs,
|
||||
tagsPartialMatch: tagsPartialMatch,
|
||||
groupIds: groupIDs,
|
||||
status: status,
|
||||
edgeDevice: edgeDevice,
|
||||
edgeDeviceUntrusted: edgeDeviceUntrusted,
|
||||
name: name,
|
||||
agentVersions: agentVersions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.Endpoint, query EnvironmentsQuery, groups []portainer.EndpointGroup, settings *portainer.Settings) ([]portainer.Endpoint, int, error) {
|
||||
totalAvailableEndpoints := len(filteredEndpoints)
|
||||
|
||||
if len(query.endpointIds) > 0 {
|
||||
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds)
|
||||
}
|
||||
|
||||
if len(query.groupIds) > 0 {
|
||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
|
||||
}
|
||||
|
||||
if query.name != "" {
|
||||
filteredEndpoints = filterEndpointsByName(filteredEndpoints, query.name)
|
||||
}
|
||||
|
||||
if query.edgeDevice != nil {
|
||||
filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, *query.edgeDevice, query.edgeDeviceUntrusted)
|
||||
} else {
|
||||
// If the edgeDevice parameter is not set, we need to filter out the untrusted edge devices
|
||||
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
|
||||
return !endpoint.IsEdgeDevice || endpoint.UserTrusted
|
||||
})
|
||||
}
|
||||
|
||||
if len(query.status) > 0 {
|
||||
filteredEndpoints = filterEndpointsByStatuses(filteredEndpoints, query.status, settings)
|
||||
}
|
||||
|
||||
if query.search != "" {
|
||||
tags, err := handler.DataStore.Tag().Tags()
|
||||
if err != nil {
|
||||
return nil, 0, errors.WithMessage(err, "Unable to retrieve tags from the database")
|
||||
}
|
||||
|
||||
tagsMap := make(map[portainer.TagID]string)
|
||||
for _, tag := range tags {
|
||||
tagsMap[tag.ID] = tag.Name
|
||||
}
|
||||
|
||||
filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, groups, tagsMap, query.search)
|
||||
}
|
||||
|
||||
if len(query.types) > 0 {
|
||||
filteredEndpoints = filterEndpointsByTypes(filteredEndpoints, query.types)
|
||||
}
|
||||
|
||||
if len(query.tagIds) > 0 {
|
||||
filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, query.tagIds, groups, query.tagsPartialMatch)
|
||||
}
|
||||
|
||||
if len(query.agentVersions) > 0 {
|
||||
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
|
||||
return !endpointutils.IsAgentEndpoint(&endpoint) || contains(query.agentVersions, endpoint.Agent.Version)
|
||||
})
|
||||
}
|
||||
|
||||
return filteredEndpoints, totalAvailableEndpoints, nil
|
||||
}
|
||||
|
||||
func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs []portainer.EndpointGroupID) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if slices.Contains(endpointGroupIDs, endpoint.GroupID) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
endpointTags := convertTagIDsToTags(tagsMap, endpoint.TagIDs)
|
||||
if endpointMatchSearchCriteria(&endpoint, endpointTags, searchCriteria) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
continue
|
||||
}
|
||||
|
||||
if endpointGroupMatchSearchCriteria(&endpoint, endpointGroups, tagsMap, searchCriteria) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []portainer.EndpointStatus, settings *portainer.Settings) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
status := endpoint.Status
|
||||
if endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||
isCheckValid := false
|
||||
edgeCheckinInterval := endpoint.EdgeCheckinInterval
|
||||
if endpoint.EdgeCheckinInterval == 0 {
|
||||
edgeCheckinInterval = settings.EdgeAgentCheckinInterval
|
||||
}
|
||||
|
||||
if edgeCheckinInterval != 0 && endpoint.LastCheckInDate != 0 {
|
||||
isCheckValid = time.Now().Unix()-endpoint.LastCheckInDate <= int64(edgeCheckinInterval*EdgeDeviceIntervalMultiplier+EdgeDeviceIntervalAdd)
|
||||
}
|
||||
|
||||
status = portainer.EndpointStatusDown // Offline
|
||||
if isCheckValid {
|
||||
status = portainer.EndpointStatusUp // Online
|
||||
}
|
||||
}
|
||||
|
||||
if slices.Contains(statuses, status) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, searchCriteria string) bool {
|
||||
if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(endpoint.URL), searchCriteria) {
|
||||
return true
|
||||
}
|
||||
|
||||
if endpoint.Status == portainer.EndpointStatusUp && searchCriteria == "up" {
|
||||
return true
|
||||
} else if endpoint.Status == portainer.EndpointStatusDown && searchCriteria == "down" {
|
||||
return true
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if strings.Contains(strings.ToLower(tag), searchCriteria) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGroups []portainer.EndpointGroup, tagsMap map[portainer.TagID]string, searchCriteria string) bool {
|
||||
for _, group := range endpointGroups {
|
||||
if group.ID == endpoint.GroupID {
|
||||
if strings.Contains(strings.ToLower(group.Name), searchCriteria) {
|
||||
return true
|
||||
}
|
||||
tags := convertTagIDsToTags(tagsMap, group.TagIDs)
|
||||
for _, tag := range tags {
|
||||
if strings.Contains(strings.ToLower(tag), searchCriteria) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []portainer.EndpointType) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
typeSet := map[portainer.EndpointType]bool{}
|
||||
for _, endpointType := range endpointTypes {
|
||||
typeSet[portainer.EndpointType(endpointType)] = true
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if typeSet[endpoint.Type] {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func filterEndpointsByEdgeDevice(endpoints []portainer.Endpoint, edgeDevice bool, untrusted bool) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if shouldReturnEdgeDevice(endpoint, edgeDevice, untrusted) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func shouldReturnEdgeDevice(endpoint portainer.Endpoint, edgeDeviceParam bool, untrustedParam bool) bool {
|
||||
if !endpointutils.IsEdgeEndpoint(&endpoint) {
|
||||
return true
|
||||
}
|
||||
|
||||
if !edgeDeviceParam {
|
||||
return !endpoint.IsEdgeDevice
|
||||
}
|
||||
|
||||
return endpoint.IsEdgeDevice && endpoint.UserTrusted == !untrustedParam
|
||||
}
|
||||
|
||||
func convertTagIDsToTags(tagsMap map[portainer.TagID]string, tagIDs []portainer.TagID) []string {
|
||||
tags := make([]string, 0)
|
||||
for _, tagID := range tagIDs {
|
||||
tags = append(tags, tagsMap[tagID])
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func filteredEndpointsByTags(endpoints []portainer.Endpoint, tagIDs []portainer.TagID, endpointGroups []portainer.EndpointGroup, partialMatch bool) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
endpointGroup := getEndpointGroup(endpoint.GroupID, endpointGroups)
|
||||
endpointMatched := false
|
||||
if partialMatch {
|
||||
endpointMatched = endpointPartialMatchTags(endpoint, endpointGroup, tagIDs)
|
||||
} else {
|
||||
endpointMatched = endpointFullMatchTags(endpoint, endpointGroup, tagIDs)
|
||||
}
|
||||
|
||||
if endpointMatched {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func endpointPartialMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool {
|
||||
tagSet := make(map[portainer.TagID]bool)
|
||||
for _, tagID := range tagIDs {
|
||||
tagSet[tagID] = true
|
||||
}
|
||||
for _, tagID := range endpoint.TagIDs {
|
||||
if tagSet[tagID] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, tagID := range endpointGroup.TagIDs {
|
||||
if tagSet[tagID] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func endpointFullMatchTags(endpoint portainer.Endpoint, endpointGroup portainer.EndpointGroup, tagIDs []portainer.TagID) bool {
|
||||
missingTags := make(map[portainer.TagID]bool)
|
||||
for _, tagID := range tagIDs {
|
||||
missingTags[tagID] = true
|
||||
}
|
||||
for _, tagID := range endpoint.TagIDs {
|
||||
if missingTags[tagID] {
|
||||
delete(missingTags, tagID)
|
||||
}
|
||||
}
|
||||
for _, tagID := range endpointGroup.TagIDs {
|
||||
if missingTags[tagID] {
|
||||
delete(missingTags, tagID)
|
||||
}
|
||||
}
|
||||
return len(missingTags) == 0
|
||||
}
|
||||
|
||||
func filteredEndpointsByIds(endpoints []portainer.Endpoint, ids []portainer.EndpointID) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
idsSet := make(map[portainer.EndpointID]bool)
|
||||
for _, id := range ids {
|
||||
idsSet[id] = true
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if idsSet[endpoint.ID] {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredEndpoints
|
||||
|
||||
}
|
||||
|
||||
func filterEndpointsByName(endpoints []portainer.Endpoint, name string) []portainer.Endpoint {
|
||||
if name == "" {
|
||||
return endpoints
|
||||
}
|
||||
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.Name == name {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func filter(endpoints []portainer.Endpoint, predicate func(endpoint portainer.Endpoint) bool) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if predicate(endpoint) {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
return filteredEndpoints
|
||||
}
|
||||
|
||||
func getArrayQueryParameter(r *http.Request, parameter string) []string {
|
||||
list, exists := r.Form[fmt.Sprintf("%s[]", parameter)]
|
||||
if !exists {
|
||||
list = []string{}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
func getNumberArrayQueryParameter[T ~int](r *http.Request, parameter string) ([]T, error) {
|
||||
list := getArrayQueryParameter(r, parameter)
|
||||
if list == nil {
|
||||
return []T{}, nil
|
||||
}
|
||||
|
||||
var result []T
|
||||
for _, item := range list {
|
||||
number, err := strconv.Atoi(item)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "Unable to parse parameter %s", parameter)
|
||||
|
||||
}
|
||||
|
||||
result = append(result, T(number))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func contains(strings []string, param string) bool {
|
||||
for _, str := range strings {
|
||||
if str == param {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
helper "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type filterTest struct {
|
||||
title string
|
||||
expected []portainer.EndpointID
|
||||
query EnvironmentsQuery
|
||||
}
|
||||
|
||||
func Test_Filter_AgentVersion(t *testing.T) {
|
||||
|
||||
version1Endpoint := portainer.Endpoint{ID: 1, GroupID: 1,
|
||||
Type: portainer.AgentOnDockerEnvironment,
|
||||
Agent: struct {
|
||||
Version string "example:\"1.0.0\""
|
||||
}{Version: "1.0.0"}}
|
||||
version2Endpoint := portainer.Endpoint{ID: 2, GroupID: 1,
|
||||
Type: portainer.AgentOnDockerEnvironment,
|
||||
Agent: struct {
|
||||
Version string "example:\"1.0.0\""
|
||||
}{Version: "2.0.0"}}
|
||||
noVersionEndpoint := portainer.Endpoint{ID: 3, GroupID: 1,
|
||||
Type: portainer.AgentOnDockerEnvironment,
|
||||
}
|
||||
notAgentEnvironments := portainer.Endpoint{ID: 4, Type: portainer.DockerEnvironment, GroupID: 1}
|
||||
|
||||
endpoints := []portainer.Endpoint{
|
||||
version1Endpoint,
|
||||
version2Endpoint,
|
||||
noVersionEndpoint,
|
||||
notAgentEnvironments,
|
||||
}
|
||||
|
||||
handler, teardown := setupFilterTest(t, endpoints)
|
||||
|
||||
defer teardown()
|
||||
|
||||
tests := []filterTest{
|
||||
{
|
||||
"should show version 1 endpoints",
|
||||
[]portainer.EndpointID{version1Endpoint.ID},
|
||||
EnvironmentsQuery{
|
||||
agentVersions: []string{version1Endpoint.Agent.Version},
|
||||
types: []portainer.EndpointType{portainer.AgentOnDockerEnvironment},
|
||||
},
|
||||
},
|
||||
{
|
||||
"should show version 2 endpoints",
|
||||
[]portainer.EndpointID{version2Endpoint.ID},
|
||||
EnvironmentsQuery{
|
||||
agentVersions: []string{version2Endpoint.Agent.Version},
|
||||
types: []portainer.EndpointType{portainer.AgentOnDockerEnvironment},
|
||||
},
|
||||
},
|
||||
{
|
||||
"should show version 1 and 2 endpoints",
|
||||
[]portainer.EndpointID{version2Endpoint.ID, version1Endpoint.ID},
|
||||
EnvironmentsQuery{
|
||||
agentVersions: []string{version2Endpoint.Agent.Version, version1Endpoint.Agent.Version},
|
||||
types: []portainer.EndpointType{portainer.AgentOnDockerEnvironment},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTests(tests, t, handler, endpoints)
|
||||
}
|
||||
|
||||
func Test_Filter_edgeDeviceFilter(t *testing.T) {
|
||||
|
||||
trustedEdgeDevice := portainer.Endpoint{ID: 1, UserTrusted: true, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
untrustedEdgeDevice := portainer.Endpoint{ID: 2, UserTrusted: false, IsEdgeDevice: true, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
regularUntrustedEdgeEndpoint := portainer.Endpoint{ID: 3, UserTrusted: false, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
regularTrustedEdgeEndpoint := portainer.Endpoint{ID: 4, UserTrusted: true, IsEdgeDevice: false, GroupID: 1, Type: portainer.EdgeAgentOnDockerEnvironment}
|
||||
regularEndpoint := portainer.Endpoint{ID: 5, GroupID: 1, Type: portainer.DockerEnvironment}
|
||||
|
||||
endpoints := []portainer.Endpoint{
|
||||
trustedEdgeDevice,
|
||||
untrustedEdgeDevice,
|
||||
regularUntrustedEdgeEndpoint,
|
||||
regularTrustedEdgeEndpoint,
|
||||
regularEndpoint,
|
||||
}
|
||||
|
||||
handler, teardown := setupFilterTest(t, endpoints)
|
||||
|
||||
defer teardown()
|
||||
|
||||
tests := []filterTest{
|
||||
{
|
||||
"should show all edge endpoints except of the untrusted devices",
|
||||
[]portainer.EndpointID{trustedEdgeDevice.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||
EnvironmentsQuery{
|
||||
types: []portainer.EndpointType{portainer.EdgeAgentOnDockerEnvironment, portainer.EdgeAgentOnKubernetesEnvironment},
|
||||
},
|
||||
},
|
||||
{
|
||||
"should show only trusted edge devices and other regular endpoints",
|
||||
[]portainer.EndpointID{trustedEdgeDevice.ID, regularEndpoint.ID},
|
||||
EnvironmentsQuery{
|
||||
edgeDevice: BoolAddr(true),
|
||||
},
|
||||
},
|
||||
{
|
||||
"should show only untrusted edge devices and other regular endpoints",
|
||||
[]portainer.EndpointID{untrustedEdgeDevice.ID, regularEndpoint.ID},
|
||||
EnvironmentsQuery{
|
||||
edgeDevice: BoolAddr(true),
|
||||
edgeDeviceUntrusted: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
"should show no edge devices",
|
||||
[]portainer.EndpointID{regularEndpoint.ID, regularUntrustedEdgeEndpoint.ID, regularTrustedEdgeEndpoint.ID},
|
||||
EnvironmentsQuery{
|
||||
edgeDevice: BoolAddr(false),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTests(tests, t, handler, endpoints)
|
||||
}
|
||||
|
||||
func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
runTest(t, test, handler, endpoints)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runTest(t *testing.T, test filterTest, handler *Handler, endpoints []portainer.Endpoint) {
|
||||
is := assert.New(t)
|
||||
|
||||
filteredEndpoints, _, err := handler.filterEndpointsByQuery(endpoints, test.query, []portainer.EndpointGroup{}, &portainer.Settings{})
|
||||
|
||||
is.NoError(err)
|
||||
|
||||
is.Equal(len(test.expected), len(filteredEndpoints))
|
||||
|
||||
respIds := []portainer.EndpointID{}
|
||||
|
||||
for _, endpoint := range filteredEndpoints {
|
||||
respIds = append(respIds, endpoint.ID)
|
||||
}
|
||||
|
||||
is.ElementsMatch(test.expected, respIds)
|
||||
|
||||
}
|
||||
|
||||
func setupFilterTest(t *testing.T, endpoints []portainer.Endpoint) (handler *Handler, teardown func()) {
|
||||
is := assert.New(t)
|
||||
_, store, teardown := datastore.MustNewTestStore(true, true)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
err := store.Endpoint().Create(&endpoint)
|
||||
is.NoError(err, "error creating environment")
|
||||
}
|
||||
|
||||
err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole})
|
||||
is.NoError(err, "error creating a user")
|
||||
|
||||
bouncer := helper.NewTestRequestBouncer()
|
||||
handler = NewHandler(bouncer, nil)
|
||||
handler.DataStore = store
|
||||
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
|
||||
|
||||
return handler, teardown
|
||||
}
|
||||
@@ -67,6 +67,9 @@ func NewHandler(bouncer requestBouncer, demoService *demo.Service) *Handler {
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost)
|
||||
h.Handle("/endpoints",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/agent_versions",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.agentVersions))).Methods(http.MethodGet)
|
||||
|
||||
h.Handle("/endpoints/{id}",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/{id}",
|
||||
|
||||
@@ -39,8 +39,8 @@ func (e EndpointsByGroup) Less(i, j int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
groupA := endpointGroupNames[e.endpoints[i].GroupID]
|
||||
groupB := endpointGroupNames[e.endpoints[j].GroupID]
|
||||
groupA := e.endpointGroupNames[e.endpoints[i].GroupID]
|
||||
groupB := e.endpointGroupNames[e.endpoints[j].GroupID]
|
||||
|
||||
return sortorder.NaturalLess(strings.ToLower(groupA), strings.ToLower(groupB))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package endpoints
|
||||
|
||||
func BoolAddr(b bool) *bool {
|
||||
boolVar := b
|
||||
return &boolVar
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/handler/auth"
|
||||
"github.com/portainer/portainer/api/http/handler/backup"
|
||||
"github.com/portainer/portainer/api/http/handler/customtemplates"
|
||||
"github.com/portainer/portainer/api/http/handler/docker"
|
||||
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
||||
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||
@@ -45,6 +46,7 @@ type Handler struct {
|
||||
AuthHandler *auth.Handler
|
||||
BackupHandler *backup.Handler
|
||||
CustomTemplatesHandler *customtemplates.Handler
|
||||
DockerHandler *docker.Handler
|
||||
EdgeGroupsHandler *edgegroups.Handler
|
||||
EdgeJobsHandler *edgejobs.Handler
|
||||
EdgeStacksHandler *edgestacks.Handler
|
||||
@@ -80,7 +82,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.14.0
|
||||
// @version 2.15.1
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
@@ -179,6 +181,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):
|
||||
http.StripPrefix("/api", h.KubernetesHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/docker"):
|
||||
http.StripPrefix("/api/docker", h.DockerHandler).ServeHTTP(w, r)
|
||||
|
||||
// Helm subpath under kubernetes -> /api/endpoints/{id}/kubernetes/helm
|
||||
case strings.HasPrefix(r.URL.Path, "/api/endpoints/") && strings.Contains(r.URL.Path, "/kubernetes/helm"):
|
||||
|
||||
@@ -2,7 +2,6 @@ package helm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/portainer/libhelm"
|
||||
@@ -108,7 +107,7 @@ func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.Kubernet
|
||||
|
||||
hostURL := "localhost"
|
||||
if !sslSettings.SelfSigned {
|
||||
hostURL = strings.Split(r.Host, ":")[0]
|
||||
hostURL = r.Host
|
||||
}
|
||||
|
||||
kubeConfigInternal := handler.kubeClusterAccessService.GetData(hostURL, endpoint.ID)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
@@ -145,8 +144,7 @@ func (handler *Handler) buildConfig(r *http.Request, tokenData *portainer.TokenD
|
||||
}
|
||||
|
||||
func (handler *Handler) buildCluster(r *http.Request, endpoint portainer.Endpoint) clientV1.NamedCluster {
|
||||
hostURL := strings.Split(r.Host, ":")[0]
|
||||
kubeConfigInternal := handler.kubeClusterAccessService.GetData(hostURL, endpoint.ID)
|
||||
kubeConfigInternal := handler.kubeClusterAccessService.GetData(r.Host, endpoint.ID)
|
||||
return clientV1.NamedCluster{
|
||||
Name: buildClusterName(endpoint.Name),
|
||||
Cluster: clientV1.Cluster{
|
||||
|
||||
@@ -30,6 +30,19 @@ type publicSettingsResponse struct {
|
||||
KubeconfigExpiry string `example:"24h" default:"0"`
|
||||
// Whether team sync is enabled
|
||||
TeamSync bool `json:"TeamSync" example:"true"`
|
||||
|
||||
Edge struct {
|
||||
// Whether the device has been started in edge async mode
|
||||
AsyncMode bool
|
||||
// The ping interval for edge agent - used in edge async mode [seconds]
|
||||
PingInterval int `json:"PingInterval" example:"60"`
|
||||
// The snapshot interval for edge agent - used in edge async mode [seconds]
|
||||
SnapshotInterval int `json:"SnapshotInterval" example:"60"`
|
||||
// The command list interval for edge agent - used in edge async mode [seconds]
|
||||
CommandInterval int `json:"CommandInterval" example:"60"`
|
||||
// The check in interval for edge agent (in seconds) - used in non async mode [seconds]
|
||||
CheckinInterval int `example:"60"`
|
||||
}
|
||||
}
|
||||
|
||||
// @id SettingsPublic
|
||||
@@ -61,6 +74,13 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
|
||||
KubeconfigExpiry: appSettings.KubeconfigExpiry,
|
||||
Features: appSettings.FeatureFlagSettings,
|
||||
}
|
||||
|
||||
publicSettings.Edge.AsyncMode = appSettings.Edge.AsyncMode
|
||||
publicSettings.Edge.PingInterval = appSettings.Edge.PingInterval
|
||||
publicSettings.Edge.SnapshotInterval = appSettings.Edge.SnapshotInterval
|
||||
publicSettings.Edge.CommandInterval = appSettings.Edge.CommandInterval
|
||||
publicSettings.Edge.CheckinInterval = appSettings.EdgeAgentCheckinInterval
|
||||
|
||||
//if OAuth authentication is on, compose the related fields from application settings
|
||||
if publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth {
|
||||
publicSettings.OAuthLogoutURI = appSettings.OAuthSettings.LogoutURI
|
||||
@@ -75,8 +95,10 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
|
||||
}
|
||||
}
|
||||
//if LDAP authentication is on, compose the related fields from application settings
|
||||
if publicSettings.AuthenticationMethod == portainer.AuthenticationLDAP {
|
||||
publicSettings.TeamSync = len(appSettings.LDAPSettings.GroupSearchSettings) > 0
|
||||
if publicSettings.AuthenticationMethod == portainer.AuthenticationLDAP && appSettings.LDAPSettings.GroupSearchSettings != nil {
|
||||
if len(appSettings.LDAPSettings.GroupSearchSettings) > 0 {
|
||||
publicSettings.TeamSync = len(appSettings.LDAPSettings.GroupSearchSettings[0].GroupBaseDN) > 0
|
||||
}
|
||||
}
|
||||
return publicSettings
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint)
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, false)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
}
|
||||
stack.GitConfig.ConfigHash = commitID
|
||||
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint)
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, false)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
}
|
||||
@@ -386,7 +386,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint)
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, false)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
}
|
||||
@@ -408,14 +408,15 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
|
||||
}
|
||||
|
||||
type composeStackDeploymentConfig struct {
|
||||
stack *portainer.Stack
|
||||
endpoint *portainer.Endpoint
|
||||
registries []portainer.Registry
|
||||
isAdmin bool
|
||||
user *portainer.User
|
||||
stack *portainer.Stack
|
||||
endpoint *portainer.Endpoint
|
||||
registries []portainer.Registry
|
||||
isAdmin bool
|
||||
user *portainer.User
|
||||
forcePullImage bool
|
||||
}
|
||||
|
||||
func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) (*composeStackDeploymentConfig, *httperror.HandlerError) {
|
||||
func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, forcePullImage bool) (*composeStackDeploymentConfig, *httperror.HandlerError) {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
|
||||
@@ -433,11 +434,12 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai
|
||||
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
|
||||
|
||||
config := &composeStackDeploymentConfig{
|
||||
stack: stack,
|
||||
endpoint: endpoint,
|
||||
registries: filteredRegistries,
|
||||
isAdmin: securityContext.IsAdmin,
|
||||
user: user,
|
||||
stack: stack,
|
||||
endpoint: endpoint,
|
||||
registries: filteredRegistries,
|
||||
isAdmin: securityContext.IsAdmin,
|
||||
user: user,
|
||||
forcePullImage: forcePullImage,
|
||||
}
|
||||
|
||||
return config, nil
|
||||
@@ -477,5 +479,5 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig,
|
||||
}
|
||||
}
|
||||
|
||||
return handler.StackDeployer.DeployComposeStack(config.stack, config.endpoint, config.registries, forceCreate)
|
||||
return handler.StackDeployer.DeployComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, forceCreate)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false, true)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
}
|
||||
@@ -226,7 +226,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
|
||||
}
|
||||
stack.GitConfig.ConfigHash = commitID
|
||||
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false, true)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false, true)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
}
|
||||
@@ -360,9 +360,10 @@ type swarmStackDeploymentConfig struct {
|
||||
prune bool
|
||||
isAdmin bool
|
||||
user *portainer.User
|
||||
pullImage bool
|
||||
}
|
||||
|
||||
func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) {
|
||||
func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool, pullImage bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
@@ -386,6 +387,7 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine
|
||||
prune: prune,
|
||||
isAdmin: securityContext.IsAdmin,
|
||||
user: user,
|
||||
pullImage: pullImage,
|
||||
}
|
||||
|
||||
return config, nil
|
||||
@@ -413,5 +415,5 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err
|
||||
}
|
||||
}
|
||||
|
||||
return handler.StackDeployer.DeploySwarmStack(config.stack, config.endpoint, config.registries, config.prune)
|
||||
return handler.StackDeployer.DeploySwarmStack(config.stack, config.endpoint, config.registries, config.prune, config.pullImage)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
@@ -133,6 +135,20 @@ func (handler *Handler) userCanCreateStack(securityContext *security.RestrictedR
|
||||
return handler.userIsAdminOrEndpointAdmin(user, endpointID)
|
||||
}
|
||||
|
||||
// if stack management is disabled for non admins and the user isn't an admin, then return false. Otherwise return true
|
||||
func (handler *Handler) userCanManageStacks(securityContext *security.RestrictedRequestContext, endpoint *portainer.Endpoint) (bool, error) {
|
||||
if endpointutils.IsDockerEndpoint(endpoint) && !endpoint.SecuritySettings.AllowStackManagementForRegularUsers {
|
||||
canCreate, err := handler.userCanCreateStack(securityContext, portainer.EndpointID(endpoint.ID))
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("Failed to get user from the database: %w", err)
|
||||
}
|
||||
|
||||
return canCreate, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) checkUniqueStackName(endpoint *portainer.Endpoint, name string, stackID portainer.StackID) (bool, error) {
|
||||
stacks, err := handler.DataStore.Stack().Stacks()
|
||||
if err != nil {
|
||||
|
||||
@@ -82,6 +82,22 @@ func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an environment with the specified identifier inside the database", Err: err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an environment with the specified identifier inside the database", Err: err}
|
||||
}
|
||||
|
||||
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack deletion", Err: err}
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack management is disabled for non-admin users"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: errMsg, Err: fmt.Errorf(errMsg)}
|
||||
}
|
||||
|
||||
stack.EndpointID = portainer.EndpointID(endpointID)
|
||||
stack.SwarmID = swarmId
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
)
|
||||
|
||||
@@ -76,22 +75,18 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
if endpointutils.IsDockerEndpoint(endpoint) && !endpoint.SecuritySettings.AllowStackManagementForRegularUsers {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err}
|
||||
}
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user info from request context", Err: err}
|
||||
}
|
||||
|
||||
canCreate, err := handler.userCanCreateStack(securityContext, portainer.EndpointID(endpointID))
|
||||
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack creation", err}
|
||||
}
|
||||
|
||||
if !canCreate {
|
||||
errMsg := "Stack creation is disabled for non-admin users"
|
||||
return &httperror.HandlerError{http.StatusForbidden, errMsg, errors.New(errMsg)}
|
||||
}
|
||||
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack deletion", Err: err}
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack creation is disabled for non-admin users"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: errMsg, Err: errors.New(errMsg)}
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
|
||||
@@ -103,6 +103,15 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
|
||||
}
|
||||
}
|
||||
|
||||
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack deletion", Err: err}
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack deletion is disabled for non-admin users"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: errMsg, Err: fmt.Errorf(errMsg)}
|
||||
}
|
||||
|
||||
// stop scheduler updates of the stack before removal
|
||||
if stack.AutoUpdate != nil {
|
||||
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
|
||||
|
||||
@@ -3,11 +3,12 @@ package stacks
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
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/http/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
)
|
||||
@@ -59,6 +60,15 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack deletion", Err: err}
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack management is disabled for non-admin users"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: errMsg, Err: errors.New(errMsg)}
|
||||
}
|
||||
|
||||
if endpoint != nil {
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
@@ -76,7 +86,7 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ package stacks
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/http/errors"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
)
|
||||
@@ -55,6 +55,15 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack deletion", Err: err}
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack management is disabled for non-admin users"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: errMsg, Err: errors.New(errMsg)}
|
||||
}
|
||||
|
||||
if endpoint != nil {
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
@@ -72,7 +81,7 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
if resourceControl != nil {
|
||||
|
||||
@@ -87,6 +87,15 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
|
||||
}
|
||||
|
||||
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack deletion", Err: err}
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack migration is disabled for non-admin users"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: errMsg, Err: errors.New(errMsg)}
|
||||
}
|
||||
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err}
|
||||
@@ -180,7 +189,7 @@ func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, ne
|
||||
}
|
||||
|
||||
func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError {
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, next)
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, next, false)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
}
|
||||
@@ -194,7 +203,7 @@ func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.St
|
||||
}
|
||||
|
||||
func (handler *Handler) migrateSwarmStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError {
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, next, true)
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, next, true, true)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
}
|
||||
|
||||
@@ -64,6 +64,15 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
|
||||
}
|
||||
|
||||
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack deletion", Err: err}
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack management is disabled for non-admin users"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: errMsg, Err: errors.New(errMsg)}
|
||||
}
|
||||
|
||||
isUnique, err := handler.checkUniqueStackNameInDocker(endpoint, stack.Name, stack.ID, stack.SwarmID != "")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
|
||||
@@ -125,7 +134,7 @@ func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.E
|
||||
case portainer.DockerComposeStack:
|
||||
return handler.ComposeStackManager.Up(context.TODO(), stack, endpoint, false)
|
||||
case portainer.DockerSwarmStack:
|
||||
return handler.SwarmStackManager.Deploy(stack, true, endpoint)
|
||||
return handler.SwarmStackManager.Deploy(stack, true, true, endpoint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -75,6 +75,15 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack deletion", Err: err}
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack management is disabled for non-admin users"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: errMsg, Err: errors.New(errMsg)}
|
||||
}
|
||||
|
||||
if stack.Status == portainer.StackStatusInactive {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Stack is already inactive", errors.New("Stack is already inactive")}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ type updateComposeStackPayload struct {
|
||||
StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx"`
|
||||
// A list of environment(endpoint) variables used during stack deployment
|
||||
Env []portainer.Pair
|
||||
// Force a pulling to current image with the original tag though the image is already the latest
|
||||
PullImage bool `example:"false"`
|
||||
}
|
||||
|
||||
func (payload *updateComposeStackPayload) Validate(r *http.Request) error {
|
||||
@@ -38,6 +40,8 @@ type updateSwarmStackPayload struct {
|
||||
Env []portainer.Pair
|
||||
// Prune services that are no longer referenced (only available for Swarm stacks)
|
||||
Prune bool `example:"true"`
|
||||
// Force a pulling to current image with the original tag though the image is already the latest
|
||||
PullImage bool `example:"false"`
|
||||
}
|
||||
|
||||
func (payload *updateSwarmStackPayload) Validate(r *http.Request) error {
|
||||
@@ -123,6 +127,15 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
|
||||
}
|
||||
}
|
||||
|
||||
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack deletion", Err: err}
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack editing is disabled for non-admin users"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: errMsg, Err: errors.New(errMsg)}
|
||||
}
|
||||
|
||||
updateError := handler.updateAndDeployStack(r, stack, endpoint)
|
||||
if updateError != nil {
|
||||
return updateError
|
||||
@@ -185,7 +198,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist updated Compose file on disk", Err: err}
|
||||
}
|
||||
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint)
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, payload.PullImage)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
}
|
||||
@@ -222,7 +235,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist updated Compose file on disk", Err: err}
|
||||
}
|
||||
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, payload.Prune)
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, payload.Prune, payload.PullImage)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
type stackGitUpdatePayload struct {
|
||||
AutoUpdate *portainer.StackAutoUpdate
|
||||
Env []portainer.Pair
|
||||
Prune bool
|
||||
RepositoryReferenceName string
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
@@ -119,6 +120,15 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack deletion", Err: err}
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack editing is disabled for non-admin users"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: errMsg, Err: errors.New(errMsg)}
|
||||
}
|
||||
|
||||
//stop the autoupdate job if there is any
|
||||
if stack.AutoUpdate != nil {
|
||||
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
|
||||
@@ -131,6 +141,12 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
|
||||
stack.UpdatedBy = user.Username
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
|
||||
if stack.Type == portainer.DockerSwarmStack {
|
||||
stack.Option = &portainer.StackOption{
|
||||
Prune: payload.Prune,
|
||||
}
|
||||
}
|
||||
|
||||
if payload.RepositoryAuthentication {
|
||||
password := payload.RepositoryPassword
|
||||
if password == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
|
||||
|
||||
@@ -24,6 +24,9 @@ type stackGitRedployPayload struct {
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
Env []portainer.Pair
|
||||
Prune bool
|
||||
// Force a pulling to current image with the original tag though the image is already the latest
|
||||
PullImage bool `example:"false"`
|
||||
}
|
||||
|
||||
func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
|
||||
@@ -110,6 +113,15 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack deletion", Err: err}
|
||||
}
|
||||
if !canManage {
|
||||
errMsg := "Stack management is disabled for non-admin users"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: errMsg, Err: errors.New(errMsg)}
|
||||
}
|
||||
|
||||
var payload stackGitRedployPayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
@@ -118,6 +130,11 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
|
||||
stack.Env = payload.Env
|
||||
if stack.Type == portainer.DockerSwarmStack {
|
||||
stack.Option = &portainer.StackOption{
|
||||
Prune: payload.Prune,
|
||||
}
|
||||
}
|
||||
|
||||
backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath)
|
||||
err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath)
|
||||
@@ -152,7 +169,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}()
|
||||
|
||||
httpErr := handler.deployStack(r, stack, endpoint)
|
||||
httpErr := handler.deployStack(r, stack, payload.PullImage, endpoint)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
@@ -184,10 +201,14 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, pullImage bool, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
switch stack.Type {
|
||||
case portainer.DockerSwarmStack:
|
||||
config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
||||
prune := false
|
||||
if stack.Option != nil {
|
||||
prune = stack.Option.Prune
|
||||
}
|
||||
config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, prune, pullImage)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
@@ -197,7 +218,7 @@ func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, end
|
||||
}
|
||||
|
||||
case portainer.DockerComposeStack:
|
||||
config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint)
|
||||
config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint, pullImage)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func NewHandler(bouncer *security.RequestBouncer, status *portainer.Status, demo
|
||||
h.Handle("/status",
|
||||
bouncer.PublicAccess(httperror.LoggerHandler(h.statusInspect))).Methods(http.MethodGet)
|
||||
h.Handle("/status/version",
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.statusInspectVersion))).Methods(http.MethodGet)
|
||||
bouncer.AuthenticatedAccess(http.HandlerFunc(h.version))).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/coreos/go-semver/semver"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
|
||||
"github.com/portainer/libhttp/response"
|
||||
)
|
||||
|
||||
type inspectVersionResponse struct {
|
||||
// Whether portainer has an update available
|
||||
UpdateAvailable bool `json:"UpdateAvailable" example:"false"`
|
||||
// The latest version available
|
||||
LatestVersion string `json:"LatestVersion" example:"2.0.0"`
|
||||
}
|
||||
|
||||
type githubData struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
|
||||
// @id StatusInspectVersion
|
||||
// @summary Check for portainer updates
|
||||
// @description Check if portainer has an update available
|
||||
// @description **Access policy**: authenticated
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @tags status
|
||||
// @produce json
|
||||
// @success 200 {object} inspectVersionResponse "Success"
|
||||
// @router /status/version [get]
|
||||
func (handler *Handler) statusInspectVersion(w http.ResponseWriter, r *http.Request) {
|
||||
motd, err := client.Get(portainer.VersionCheckURL, 5)
|
||||
if err != nil {
|
||||
response.JSON(w, &inspectVersionResponse{UpdateAvailable: false})
|
||||
return
|
||||
}
|
||||
|
||||
var data githubData
|
||||
err = json.Unmarshal(motd, &data)
|
||||
if err != nil {
|
||||
response.JSON(w, &inspectVersionResponse{UpdateAvailable: false})
|
||||
return
|
||||
}
|
||||
|
||||
resp := inspectVersionResponse{
|
||||
UpdateAvailable: false,
|
||||
}
|
||||
|
||||
currentVersion := semver.New(portainer.APIVersion)
|
||||
latestVersion := semver.New(data.TagName)
|
||||
if currentVersion.LessThan(*latestVersion) {
|
||||
resp.UpdateAvailable = true
|
||||
resp.LatestVersion = data.TagName
|
||||
}
|
||||
|
||||
response.JSON(w, &resp)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/coreos/go-semver/semver"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/build"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
|
||||
"github.com/portainer/libhttp/response"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type versionResponse struct {
|
||||
// Whether portainer has an update available
|
||||
UpdateAvailable bool `json:"UpdateAvailable" example:"false"`
|
||||
// The latest version available
|
||||
LatestVersion string `json:"LatestVersion" example:"2.0.0"`
|
||||
|
||||
ServerVersion string
|
||||
DatabaseVersion string
|
||||
Build BuildInfo
|
||||
}
|
||||
|
||||
type BuildInfo struct {
|
||||
BuildNumber string
|
||||
ImageTag string
|
||||
NodejsVersion string
|
||||
YarnVersion string
|
||||
WebpackVersion string
|
||||
GoVersion string
|
||||
}
|
||||
|
||||
// @id Version
|
||||
// @summary Check for portainer updates
|
||||
// @description Check if portainer has an update available
|
||||
// @description **Access policy**: authenticated
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @tags status
|
||||
// @produce json
|
||||
// @success 200 {object} versionResponse "Success"
|
||||
// @router /status/version [get]
|
||||
func (handler *Handler) version(w http.ResponseWriter, r *http.Request) {
|
||||
result := &versionResponse{
|
||||
ServerVersion: portainer.APIVersion,
|
||||
DatabaseVersion: strconv.Itoa(portainer.DBVersion),
|
||||
Build: BuildInfo{
|
||||
BuildNumber: build.BuildNumber,
|
||||
ImageTag: build.ImageTag,
|
||||
NodejsVersion: build.NodejsVersion,
|
||||
YarnVersion: build.YarnVersion,
|
||||
WebpackVersion: build.WebpackVersion,
|
||||
GoVersion: build.GoVersion,
|
||||
},
|
||||
}
|
||||
|
||||
latestVersion := getLatestVersion()
|
||||
if hasNewerVersion(portainer.APIVersion, latestVersion) {
|
||||
result.UpdateAvailable = true
|
||||
result.LatestVersion = latestVersion
|
||||
}
|
||||
|
||||
response.JSON(w, &result)
|
||||
}
|
||||
|
||||
func getLatestVersion() string {
|
||||
motd, err := client.Get(portainer.VersionCheckURL, 5)
|
||||
if err != nil {
|
||||
log.WithError(err).Debug("couldn't fetch latest Portainer release version")
|
||||
return ""
|
||||
}
|
||||
|
||||
var data struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(motd, &data)
|
||||
if err != nil {
|
||||
log.WithError(err).Debug("couldn't parse latest Portainer version")
|
||||
return ""
|
||||
}
|
||||
|
||||
return data.TagName
|
||||
}
|
||||
|
||||
func hasNewerVersion(currentVersion, latestVersion string) bool {
|
||||
currentVersionSemver, err := semver.NewVersion(currentVersion)
|
||||
if err != nil {
|
||||
log.WithField("version", currentVersion).Debug("current Portainer version isn't a semver")
|
||||
return false
|
||||
}
|
||||
|
||||
latestVersionSemver, err := semver.NewVersion(latestVersion)
|
||||
if err != nil {
|
||||
log.WithField("version", latestVersion).Debug("latest Portainer version isn't a semver")
|
||||
return false
|
||||
}
|
||||
|
||||
return currentVersionSemver.LessThan(*latestVersionSemver)
|
||||
}
|
||||
@@ -24,11 +24,14 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
adminRouter := h.NewRoute().Subrouter()
|
||||
adminRouter.Use(bouncer.AdminAccess)
|
||||
|
||||
restrictedRouter := h.NewRoute().Subrouter()
|
||||
restrictedRouter.Use(bouncer.RestrictedAccess)
|
||||
|
||||
teamLeaderRouter := h.NewRoute().Subrouter()
|
||||
teamLeaderRouter.Use(bouncer.TeamLeaderAccess)
|
||||
|
||||
adminRouter.Handle("/teams", httperror.LoggerHandler(h.teamCreate)).Methods(http.MethodPost)
|
||||
teamLeaderRouter.Handle("/teams", httperror.LoggerHandler(h.teamList)).Methods(http.MethodGet)
|
||||
restrictedRouter.Handle("/teams", httperror.LoggerHandler(h.teamList)).Methods(http.MethodGet)
|
||||
teamLeaderRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamInspect)).Methods(http.MethodGet)
|
||||
adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamUpdate)).Methods(http.MethodPut)
|
||||
adminRouter.Handle("/teams/{id}", httperror.LoggerHandler(h.teamDelete)).Methods(http.MethodDelete)
|
||||
|
||||
@@ -82,7 +82,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Specified password do not match actual password", httperrors.ErrUnauthorized}
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Current password doesn't match", errors.New("Current password does not match the password provided. Please try again")}
|
||||
}
|
||||
|
||||
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
|
||||
|
||||
@@ -5,14 +5,13 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/agent"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/internal/url"
|
||||
)
|
||||
|
||||
// ProxyServer provide an extended proxy with a local server to forward requests
|
||||
@@ -34,7 +33,7 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
|
||||
urlString = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
|
||||
}
|
||||
|
||||
endpointURL, err := parseURL(urlString)
|
||||
endpointURL, err := url.ParseURL(urlString)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed parsing url %s", endpoint.URL)
|
||||
}
|
||||
@@ -99,15 +98,3 @@ func (proxy *ProxyServer) Close() {
|
||||
proxy.server.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// parseURL parses the endpointURL using url.Parse.
|
||||
//
|
||||
// to prevent an error when url has port but no protocol prefix
|
||||
// we add `//` prefix if needed
|
||||
func parseURL(endpointURL string) (*url.URL, error) {
|
||||
if !strings.HasPrefix(endpointURL, "http") && !strings.HasPrefix(endpointURL, "tcp") && !strings.HasPrefix(endpointURL, "//") {
|
||||
endpointURL = fmt.Sprintf("//%s", endpointURL)
|
||||
}
|
||||
|
||||
return url.Parse(endpointURL)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/docker"
|
||||
"github.com/portainer/portainer/api/internal/url"
|
||||
)
|
||||
|
||||
func (factory *ProxyFactory) newDockerProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
@@ -23,7 +23,7 @@ func (factory *ProxyFactory) newDockerProxy(endpoint *portainer.Endpoint) (http.
|
||||
}
|
||||
|
||||
func (factory *ProxyFactory) newDockerLocalProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
endpointURL, err := url.Parse(endpoint.URL)
|
||||
endpointURL, err := url.ParseURL(endpoint.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
|
||||
rawURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
|
||||
}
|
||||
|
||||
endpointURL, err := url.Parse(rawURL)
|
||||
endpointURL, err := url.ParseURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type (
|
||||
}
|
||||
|
||||
portainerRegistryAuthenticationHeader struct {
|
||||
RegistryId portainer.RegistryID `json:"registryId"`
|
||||
RegistryId *portainer.RegistryID `json:"registryId"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -446,7 +446,20 @@ func (transport *Transport) decorateRegistryAuthenticationHeader(request *http.R
|
||||
return err
|
||||
}
|
||||
|
||||
authenticationHeader, err := createRegistryAuthenticationHeader(transport.dataStore, originalHeaderData.RegistryId, accessContext)
|
||||
// delete header and exist function without error if Front End
|
||||
// passes empty json. This is to restore original behavior which
|
||||
// never originally passed this header
|
||||
if string(decodedHeaderData) == "{}" {
|
||||
request.Header.Del("X-Registry-Auth")
|
||||
return nil
|
||||
}
|
||||
|
||||
// only set X-Registry-Auth if registryId is defined
|
||||
if originalHeaderData.RegistryId == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
authenticationHeader, err := createRegistryAuthenticationHeader(transport.dataStore, *originalHeaderData.RegistryId, accessContext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -81,11 +81,11 @@ func FilterRegistries(registries []portainer.Registry, user *portainer.User, tea
|
||||
}
|
||||
|
||||
// FilterEndpoints filters environments(endpoints) based on user role and team memberships.
|
||||
// Non administrator and non-team-leader only have access to authorized environments(endpoints) (can be inherited via endpoint groups).
|
||||
// Non administrator only have access to authorized environments(endpoints) (can be inherited via endpoint groups).
|
||||
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint {
|
||||
filteredEndpoints := endpoints
|
||||
|
||||
if !context.IsAdmin && !context.IsTeamLeader {
|
||||
if !context.IsAdmin {
|
||||
filteredEndpoints = make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
@@ -101,11 +101,11 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint
|
||||
}
|
||||
|
||||
// FilterEndpointGroups filters environment(endpoint) groups based on user role and team memberships.
|
||||
// Non administrator users and Non-team-leaders only have access to authorized environment(endpoint) groups.
|
||||
// Non administrator users only have access to authorized environment(endpoint) groups.
|
||||
func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.EndpointGroup {
|
||||
filteredEndpointGroups := endpointGroups
|
||||
|
||||
if !context.IsAdmin && !context.IsTeamLeader {
|
||||
if !context.IsAdmin {
|
||||
filteredEndpointGroups = make([]portainer.EndpointGroup, 0)
|
||||
|
||||
for _, group := range endpointGroups {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/handler/auth"
|
||||
"github.com/portainer/portainer/api/http/handler/backup"
|
||||
"github.com/portainer/portainer/api/http/handler/customtemplates"
|
||||
dockerhandler "github.com/portainer/portainer/api/http/handler/docker"
|
||||
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
||||
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||
@@ -184,6 +185,8 @@ func (server *Server) Start() error {
|
||||
|
||||
var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.JWTService, server.KubeClusterAccessService, server.KubernetesClientFactory)
|
||||
|
||||
var dockerHandler = dockerhandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.DockerClientFactory)
|
||||
|
||||
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"), adminMonitor.WasInstanceDisabled)
|
||||
|
||||
var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.JWTService, server.KubernetesDeployer, server.HelmPackageManager, server.KubeClusterAccessService)
|
||||
@@ -275,6 +278,7 @@ func (server *Server) Start() error {
|
||||
AuthHandler: authHandler,
|
||||
BackupHandler: backupHandler,
|
||||
CustomTemplatesHandler: customTemplatesHandler,
|
||||
DockerHandler: dockerHandler,
|
||||
EdgeGroupsHandler: edgeGroupsHandler,
|
||||
EdgeJobsHandler: edgeJobsHandler,
|
||||
EdgeStacksHandler: edgeStacksHandler,
|
||||
|
||||
@@ -37,7 +37,7 @@ func parseRegToken(registry *portainer.Registry) (username, password string, err
|
||||
func EnsureRegTokenValid(dataStore dataservices.DataStore, registry *portainer.Registry) (err error) {
|
||||
if registry.Type == portainer.EcrRegistry {
|
||||
if isRegTokenValid(registry) {
|
||||
log.Println("[DEBUG] [registry, GetEcrAccessToken] [message: curretn ECR token is still valid]")
|
||||
log.Println("[DEBUG] [registry, GetEcrAccessToken] [message: current ECR token is still valid]")
|
||||
} else {
|
||||
err = doGetRegToken(dataStore, registry)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package set
|
||||
|
||||
type SetKey interface {
|
||||
~int | ~string
|
||||
}
|
||||
|
||||
type Set[T SetKey] map[T]bool
|
||||
|
||||
func (s Set[T]) Add(key T) {
|
||||
s[key] = true
|
||||
}
|
||||
|
||||
func (s Set[T]) Contains(key T) bool {
|
||||
_, ok := s[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s Set[T]) Remove(key T) {
|
||||
delete(s, key)
|
||||
}
|
||||
|
||||
func (s Set[T]) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func (s Set[T]) IsEmpty() bool {
|
||||
return len(s) == 0
|
||||
}
|
||||
|
||||
func (s Set[T]) Keys() []T {
|
||||
keys := make([]T, s.Len())
|
||||
|
||||
i := 0
|
||||
for k := range s {
|
||||
keys[i] = k
|
||||
i++
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
@@ -2,11 +2,14 @@ package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/agent"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
|
||||
@@ -87,6 +90,24 @@ func SupportDirectSnapshot(endpoint *portainer.Endpoint) bool {
|
||||
// SnapshotEndpoint will create a snapshot of the environment(endpoint) based on the environment(endpoint) type.
|
||||
// If the snapshot is a success, it will be associated to the environment(endpoint).
|
||||
func (service *Service) SnapshotEndpoint(endpoint *portainer.Endpoint) error {
|
||||
if endpoint.Type == portainer.AgentOnDockerEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment {
|
||||
var err error
|
||||
var tlsConfig *tls.Config
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err = crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, version, err := agent.GetAgentVersionAndPlatform(endpoint.URL, tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpoint.Agent.Version = version
|
||||
}
|
||||
|
||||
switch endpoint.Type {
|
||||
case portainer.AzureEnvironment:
|
||||
return nil
|
||||
@@ -175,6 +196,7 @@ func (service *Service) snapshotEndpoints() error {
|
||||
|
||||
latestEndpointReference.Snapshots = endpoint.Snapshots
|
||||
latestEndpointReference.Kubernetes.Snapshots = endpoint.Kubernetes.Snapshots
|
||||
latestEndpointReference.Agent.Version = endpoint.Agent.Version
|
||||
|
||||
err = service.dataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package url
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseURL parses the endpointURL using url.Parse.
|
||||
//
|
||||
// to prevent an error when url has port but no protocol prefix
|
||||
// we add `//` prefix if needed
|
||||
func ParseURL(endpointURL string) (*url.URL, error) {
|
||||
|
||||
if !strings.HasPrefix(endpointURL, "http") &&
|
||||
!strings.HasPrefix(endpointURL, "tcp") &&
|
||||
!strings.HasPrefix(endpointURL, "//") &&
|
||||
!strings.HasPrefix(endpointURL, `unix:`) &&
|
||||
!strings.HasPrefix(endpointURL, `npipe:`) {
|
||||
endpointURL = fmt.Sprintf("//%s", endpointURL)
|
||||
}
|
||||
|
||||
return url.Parse(endpointURL)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// KubeClusterAccessService represents a service that is responsible for centralizing kube cluster access data
|
||||
@@ -94,11 +95,20 @@ func (service *kubeClusterAccessService) IsSecure() bool {
|
||||
// - pass down params to binaries
|
||||
func (service *kubeClusterAccessService) GetData(hostURL string, endpointID portainer.EndpointID) kubernetesClusterAccessData {
|
||||
baseURL := service.baseURL
|
||||
|
||||
// When the api call is internal, the baseURL should not be used.
|
||||
if hostURL == "localhost" {
|
||||
hostURL = hostURL + service.httpsBindAddr
|
||||
baseURL = "/"
|
||||
}
|
||||
|
||||
if baseURL != "/" {
|
||||
baseURL = fmt.Sprintf("/%s/", strings.Trim(baseURL, "/"))
|
||||
}
|
||||
|
||||
clusterURL := hostURL + service.httpsBindAddr + baseURL
|
||||
logrus.Infof("[kubeconfig] [hostURL: %s, httpsBindAddr: %s, baseURL: %s]", hostURL, service.httpsBindAddr, baseURL)
|
||||
|
||||
clusterURL := hostURL + baseURL
|
||||
|
||||
clusterServerURL := fmt.Sprintf("https://%sapi/endpoints/%d/kubernetes", clusterURL, endpointID)
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestKubeClusterAccessService_GetKubeConfigInternal(t *testing.T) {
|
||||
clusterAccessDetails := kcs.GetData("mysite.com", 1)
|
||||
|
||||
wantClusterAccessDetails := kubernetesClusterAccessData{
|
||||
ClusterServerURL: "https://mysite.com:9443/api/endpoints/1/kubernetes",
|
||||
ClusterServerURL: "https://mysite.com/api/endpoints/1/kubernetes",
|
||||
CertificateAuthorityFile: "",
|
||||
CertificateAuthorityData: "",
|
||||
}
|
||||
|
||||
+74
-37
@@ -3,16 +3,18 @@ package oauth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Service represents a service used to authenticate users against an authorization server
|
||||
@@ -29,17 +31,39 @@ func NewService() *Service {
|
||||
func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
token, err := getOAuthToken(code, configuration)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] - Failed retrieving access token: %v", err)
|
||||
log.Debugf("[internal,oauth] [message: failed retrieving oauth token: %v]", err)
|
||||
return "", err
|
||||
}
|
||||
username, err := getUsername(token.AccessToken, configuration)
|
||||
|
||||
idToken, err := getIdToken(token)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] - Failed retrieving oauth user name: %v", err)
|
||||
log.Debugf("[internal,oauth] [message: failed parsing id_token: %v]", err)
|
||||
}
|
||||
|
||||
resource, err := getResource(token.AccessToken, configuration)
|
||||
if err != nil {
|
||||
log.Debugf("[internal,oauth] [message: failed retrieving resource: %v]", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
resource = mergeSecondIntoFirst(idToken, resource)
|
||||
|
||||
username, err := getUsername(resource, configuration)
|
||||
if err != nil {
|
||||
log.Debugf("[internal,oauth] [message: failed retrieving username: %v]", err)
|
||||
return "", err
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
// mergeSecondIntoFirst merges the overlap map into the base overwriting any existing values.
|
||||
func mergeSecondIntoFirst(base map[string]interface{}, overlap map[string]interface{}) map[string]interface{} {
|
||||
for k, v := range overlap {
|
||||
base[k] = v
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func getOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
unescapedCode, err := url.QueryUnescape(code)
|
||||
if err != nil {
|
||||
@@ -55,27 +79,55 @@ func getOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func getUsername(token string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
// getIdToken retrieves parsed id_token from the OAuth token response.
|
||||
// This is necessary for OAuth providers like Azure
|
||||
// that do not provide information about user groups on the user resource endpoint.
|
||||
func getIdToken(token *oauth2.Token) (map[string]interface{}, error) {
|
||||
tokenData := make(map[string]interface{})
|
||||
|
||||
idToken := token.Extra("id_token")
|
||||
if idToken == nil {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
jwtParser := jwt.Parser{
|
||||
SkipClaimsValidation: true,
|
||||
}
|
||||
|
||||
t, _, err := jwtParser.ParseUnverified(idToken.(string), jwt.MapClaims{})
|
||||
if err != nil {
|
||||
return tokenData, errors.Wrap(err, "failed to parse id_token")
|
||||
}
|
||||
|
||||
if claims, ok := t.Claims.(jwt.MapClaims); ok {
|
||||
for k, v := range claims {
|
||||
tokenData[k] = v
|
||||
}
|
||||
}
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
func getResource(token string, configuration *portainer.OAuthSettings) (map[string]interface{}, error) {
|
||||
req, err := http.NewRequest("GET", configuration.ResourceURI, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", &oauth2.RetrieveError{
|
||||
return nil, &oauth2.RetrieveError{
|
||||
Response: resp,
|
||||
Body: body,
|
||||
}
|
||||
@@ -83,47 +135,32 @@ func getUsername(token string, configuration *portainer.OAuthSettings) (string,
|
||||
|
||||
content, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if content == "application/x-www-form-urlencoded" || content == "text/plain" {
|
||||
values, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
username := values.Get(configuration.UserIdentifier)
|
||||
if username == "" {
|
||||
return username, &oauth2.RetrieveError{
|
||||
Response: resp,
|
||||
Body: body,
|
||||
datamap := make(map[string]interface{})
|
||||
for k, v := range values {
|
||||
if len(v) == 0 {
|
||||
datamap[k] = ""
|
||||
} else {
|
||||
datamap[k] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
return username, nil
|
||||
return datamap, nil
|
||||
}
|
||||
|
||||
var datamap map[string]interface{}
|
||||
if err = json.Unmarshal(body, &datamap); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
username, ok := datamap[configuration.UserIdentifier].(string)
|
||||
if ok && username != "" {
|
||||
return username, nil
|
||||
}
|
||||
|
||||
if !ok {
|
||||
username, ok := datamap[configuration.UserIdentifier].(float64)
|
||||
if ok && username != 0 {
|
||||
return fmt.Sprint(int(username)), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", &oauth2.RetrieveError{
|
||||
Response: resp,
|
||||
Body: body,
|
||||
}
|
||||
return datamap, nil
|
||||
}
|
||||
|
||||
func buildConfig(configuration *portainer.OAuthSettings) *oauth2.Config {
|
||||
@@ -137,6 +174,6 @@ func buildConfig(configuration *portainer.OAuthSettings) *oauth2.Config {
|
||||
ClientSecret: configuration.ClientSecret,
|
||||
Endpoint: endpoint,
|
||||
RedirectURL: configuration.RedirectURI,
|
||||
Scopes: []string{configuration.Scopes},
|
||||
Scopes: strings.Split(configuration.Scopes, ","),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func getUsername(datamap map[string]interface{}, configuration *portainer.OAuthSettings) (string, error) {
|
||||
username, ok := datamap[configuration.UserIdentifier].(string)
|
||||
if ok && username != "" {
|
||||
return username, nil
|
||||
}
|
||||
|
||||
if !ok {
|
||||
username, ok := datamap[configuration.UserIdentifier].(float64)
|
||||
if ok && username != 0 {
|
||||
return fmt.Sprint(int(username)), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("failed to extract username from oauth resource")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portaineree "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func Test_getUsername(t *testing.T) {
|
||||
t.Run("fails for non-matching user identifier", func(t *testing.T) {
|
||||
oauthSettings := &portaineree.OAuthSettings{UserIdentifier: "username"}
|
||||
datamap := map[string]interface{}{"name": "john"}
|
||||
|
||||
_, err := getUsername(datamap, oauthSettings)
|
||||
if err == nil {
|
||||
t.Errorf("getUsername should fail if user identifier doesn't exist as key in oauth userinfo object")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails if username is empty string", func(t *testing.T) {
|
||||
oauthSettings := &portaineree.OAuthSettings{UserIdentifier: "username"}
|
||||
datamap := map[string]interface{}{"username": ""}
|
||||
|
||||
_, err := getUsername(datamap, oauthSettings)
|
||||
if err == nil {
|
||||
t.Errorf("getUsername should fail if username from oauth userinfo object is empty string")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails if username is 0 int", func(t *testing.T) {
|
||||
oauthSettings := &portaineree.OAuthSettings{UserIdentifier: "username"}
|
||||
datamap := map[string]interface{}{"username": 0}
|
||||
|
||||
_, err := getUsername(datamap, oauthSettings)
|
||||
if err == nil {
|
||||
t.Errorf("getUsername should fail if username from oauth userinfo object is 0 val int")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fails if username is negative int", func(t *testing.T) {
|
||||
oauthSettings := &portaineree.OAuthSettings{UserIdentifier: "username"}
|
||||
datamap := map[string]interface{}{"username": -1}
|
||||
|
||||
_, err := getUsername(datamap, oauthSettings)
|
||||
if err == nil {
|
||||
t.Errorf("getUsername should fail if username from oauth userinfo object is -1 (negative) int")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("succeeds if username is matched and is not empty", func(t *testing.T) {
|
||||
oauthSettings := &portaineree.OAuthSettings{UserIdentifier: "username"}
|
||||
datamap := map[string]interface{}{"username": "john"}
|
||||
|
||||
_, err := getUsername(datamap, oauthSettings)
|
||||
if err != nil {
|
||||
t.Errorf("getUsername should succeed if username from oauth userinfo object matched and non-empty")
|
||||
}
|
||||
})
|
||||
|
||||
// looks like a bug!?
|
||||
t.Run("fails if username is matched and is positive int", func(t *testing.T) {
|
||||
oauthSettings := &portaineree.OAuthSettings{UserIdentifier: "username"}
|
||||
datamap := map[string]interface{}{"username": 1}
|
||||
|
||||
_, err := getUsername(datamap, oauthSettings)
|
||||
if err == nil {
|
||||
t.Errorf("getUsername should fail if username from oauth userinfo object matched is positive int")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("succeeds if username is matched and is non-zero (or negative) float", func(t *testing.T) {
|
||||
oauthSettings := &portaineree.OAuthSettings{UserIdentifier: "username"}
|
||||
datamap := map[string]interface{}{"username": 1.1}
|
||||
|
||||
_, err := getUsername(datamap, oauthSettings)
|
||||
if err != nil {
|
||||
t.Errorf("getUsername should succeed if username from oauth userinfo object matched and non-zero (or negative)")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/oauth/oauthtest"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func Test_getOAuthToken(t *testing.T) {
|
||||
validCode := "valid-code"
|
||||
srv, config := oauthtest.RunOAuthServer(validCode, &portainer.OAuthSettings{})
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("getOAuthToken fails upon invalid code", func(t *testing.T) {
|
||||
code := ""
|
||||
_, err := getOAuthToken(code, config)
|
||||
if err == nil {
|
||||
t.Errorf("getOAuthToken should fail upon providing invalid code; code=%v", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("getOAuthToken succeeds upon providing valid code", func(t *testing.T) {
|
||||
code := validCode
|
||||
token, err := getOAuthToken(code, config)
|
||||
|
||||
if token == nil || err != nil {
|
||||
t.Errorf("getOAuthToken should successfully return access token upon providing valid code")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func Test_getIdToken(t *testing.T) {
|
||||
verifiedToken := `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2NTM1NDA3MjksImV4cCI6MTY4NTA3NjcyOSwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2huIiwiU3VybmFtZSI6IkRvZSIsIkdyb3VwcyI6WyJGaXJzdCIsIlNlY29uZCJdfQ.GeU8XCV4Y4p5Vm-i63Aj7UP5zpb_0Zxb7-DjM2_z-s8`
|
||||
nonVerifiedToken := `eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJPbmxpbmUgSldUIEJ1aWxkZXIiLCJpYXQiOjE2NTM1NDA3MjksImV4cCI6MTY4NTA3NjcyOSwiYXVkIjoid3d3LmV4YW1wbGUuY29tIiwic3ViIjoiam9obi5kb2VAZXhhbXBsZS5jb20iLCJHaXZlbk5hbWUiOiJKb2huIiwiU3VybmFtZSI6IkRvZSIsIkdyb3VwcyI6WyJGaXJzdCIsIlNlY29uZCJdfQ.`
|
||||
claims := map[string]interface{}{
|
||||
"iss": "Online JWT Builder",
|
||||
"iat": float64(1653540729),
|
||||
"exp": float64(1685076729),
|
||||
"aud": "www.example.com",
|
||||
"sub": "john.doe@example.com",
|
||||
"GivenName": "John",
|
||||
"Surname": "Doe",
|
||||
"Groups": []interface{}{"First", "Second"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
testName string
|
||||
idToken string
|
||||
expectedResult map[string]interface{}
|
||||
expectedError error
|
||||
}{
|
||||
{
|
||||
testName: "should return claims if token exists and is verified",
|
||||
idToken: verifiedToken,
|
||||
expectedResult: claims,
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "should return claims if token exists but is not verified",
|
||||
idToken: nonVerifiedToken,
|
||||
expectedResult: claims,
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
testName: "should return empty map if token does not exist",
|
||||
idToken: "",
|
||||
expectedResult: make(map[string]interface{}),
|
||||
expectedError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.testName, func(t *testing.T) {
|
||||
token := &oauth2.Token{}
|
||||
if tc.idToken != "" {
|
||||
token = token.WithExtra(map[string]interface{}{"id_token": tc.idToken})
|
||||
}
|
||||
|
||||
result, err := getIdToken(token)
|
||||
assert.Equal(t, err, tc.expectedError)
|
||||
assert.Equal(t, result, tc.expectedResult)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getResource(t *testing.T) {
|
||||
srv, config := oauthtest.RunOAuthServer("", &portainer.OAuthSettings{})
|
||||
defer srv.Close()
|
||||
|
||||
t.Run("should fail upon missing Authorization Bearer header", func(t *testing.T) {
|
||||
_, err := getResource("", config)
|
||||
if err == nil {
|
||||
t.Errorf("getResource should fail if access token is not provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should fail upon providing incorrect Authorization Bearer header", func(t *testing.T) {
|
||||
_, err := getResource("incorrect-token", config)
|
||||
if err == nil {
|
||||
t.Errorf("getResource should fail if incorrect access token provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should succeed upon providing correct Authorization Bearer header", func(t *testing.T) {
|
||||
_, err := getResource(oauthtest.AccessToken, config)
|
||||
if err != nil {
|
||||
t.Errorf("getResource should succeed if correct access token provided in auth bearer header")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func Test_Authenticate(t *testing.T) {
|
||||
code := "valid-code"
|
||||
authService := NewService()
|
||||
|
||||
t.Run("should fail if user identifier does not get matched in resource", func(t *testing.T) {
|
||||
srv, config := oauthtest.RunOAuthServer(code, &portainer.OAuthSettings{})
|
||||
defer srv.Close()
|
||||
|
||||
_, err := authService.Authenticate(code, config)
|
||||
if err == nil {
|
||||
t.Error("Authenticate should fail to extract username from resource if incorrect UserIdentifier provided")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should succeed if user identifier does get matched in resource", func(t *testing.T) {
|
||||
config := &portainer.OAuthSettings{UserIdentifier: "username"}
|
||||
srv, config := oauthtest.RunOAuthServer(code, config)
|
||||
defer srv.Close()
|
||||
|
||||
username, err := authService.Authenticate(code, config)
|
||||
if err != nil {
|
||||
t.Errorf("Authenticate should succeed to extract username from resource if correct UserIdentifier provided; UserIdentifier=%s", config.UserIdentifier)
|
||||
}
|
||||
|
||||
want := "test-oauth-user"
|
||||
if username != want {
|
||||
t.Errorf("Authenticate should return correct username; got=%s, want=%s", username, want)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package oauthtest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
const (
|
||||
AccessToken = "test-token"
|
||||
)
|
||||
|
||||
// OAuthRoutes is an OAuth 2.0 compliant handler
|
||||
func OAuthRoutes(code string, config *portainer.OAuthSettings) http.Handler {
|
||||
router := mux.NewRouter()
|
||||
|
||||
router.HandleFunc(
|
||||
"/authorize",
|
||||
func(w http.ResponseWriter, req *http.Request) {
|
||||
location := fmt.Sprintf("%s?code=%s&state=%s", config.RedirectURI, code, "anything")
|
||||
// w.Header().Set("Location", location)
|
||||
// w.WriteHeader(http.StatusFound)
|
||||
http.Redirect(w, req, location, http.StatusFound)
|
||||
},
|
||||
).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc(
|
||||
"/access_token",
|
||||
func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := req.ParseForm(); err != nil {
|
||||
fmt.Fprintf(w, "ParseForm() err: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
reqCode := req.FormValue("code")
|
||||
if reqCode != code {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 86400,
|
||||
"access_token": AccessToken,
|
||||
"scope": "groups",
|
||||
})
|
||||
},
|
||||
).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc(
|
||||
"/user",
|
||||
func(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
splitToken := strings.Split(authHeader, "Bearer ")
|
||||
if len(splitToken) < 2 || splitToken[1] != AccessToken {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"username": "test-oauth-user",
|
||||
"groups": "testing",
|
||||
})
|
||||
},
|
||||
).Methods(http.MethodGet)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
// RunOAuthServer is a barebones OAuth 2.0 compliant test server which can be used to test OAuth 2 functionality
|
||||
func RunOAuthServer(code string, config *portainer.OAuthSettings) (*httptest.Server, *portainer.OAuthSettings) {
|
||||
srv := httptest.NewUnstartedServer(http.DefaultServeMux)
|
||||
|
||||
addr := srv.Listener.Addr()
|
||||
|
||||
config.AuthorizationURI = fmt.Sprintf("http://%s/authorize", addr)
|
||||
config.AccessTokenURI = fmt.Sprintf("http://%s/access_token", addr)
|
||||
config.ResourceURI = fmt.Sprintf("http://%s/user", addr)
|
||||
config.RedirectURI = fmt.Sprintf("http://%s/", addr)
|
||||
|
||||
srv.Config.Handler = OAuthRoutes(code, config)
|
||||
srv.Start()
|
||||
|
||||
return srv, config
|
||||
}
|
||||
+41
-3
@@ -199,6 +199,8 @@ type (
|
||||
StackCount int `json:"StackCount"`
|
||||
SnapshotRaw DockerSnapshotRaw `json:"DockerSnapshotRaw"`
|
||||
NodeCount int `json:"NodeCount"`
|
||||
GpuUseAll bool `json:"GpuUseAll"`
|
||||
GpuUseList []string `json:"GpuUseList"`
|
||||
}
|
||||
|
||||
// DockerSnapshotRaw represents all the information related to a snapshot as returned by the Docker API
|
||||
@@ -310,6 +312,7 @@ type (
|
||||
GroupID EndpointGroupID `json:"GroupId" example:"1"`
|
||||
// URL or IP address where exposed containers will be reachable
|
||||
PublicURL string `json:"PublicURL" example:"docker.mydomain.tld:2375"`
|
||||
Gpus []Pair `json:"Gpus"`
|
||||
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
||||
AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty" example:""`
|
||||
// List of tag identifiers to which this environment(endpoint) is associated
|
||||
@@ -345,6 +348,21 @@ type (
|
||||
// Whether the device has been trusted or not by the user
|
||||
UserTrusted bool
|
||||
|
||||
Edge struct {
|
||||
// Whether the device has been started in edge async mode
|
||||
AsyncMode bool
|
||||
// The ping interval for edge agent - used in edge async mode [seconds]
|
||||
PingInterval int `json:"PingInterval" example:"60"`
|
||||
// The snapshot interval for edge agent - used in edge async mode [seconds]
|
||||
SnapshotInterval int `json:"SnapshotInterval" example:"60"`
|
||||
// The command list interval for edge agent - used in edge async mode [seconds]
|
||||
CommandInterval int `json:"CommandInterval" example:"60"`
|
||||
}
|
||||
|
||||
Agent struct {
|
||||
Version string `example:"1.0.0"`
|
||||
}
|
||||
|
||||
// Deprecated fields
|
||||
// Deprecated in DBVersion == 4
|
||||
TLS bool `json:"TLS,omitempty"`
|
||||
@@ -837,6 +855,17 @@ type (
|
||||
// EdgePortainerURL is the URL that is exposed to edge agents
|
||||
EdgePortainerURL string `json:"EdgePortainerUrl"`
|
||||
|
||||
Edge struct {
|
||||
// The command list interval for edge agent - used in edge async mode (in seconds)
|
||||
CommandInterval int `json:"CommandInterval" example:"5"`
|
||||
// The ping interval for edge agent - used in edge async mode (in seconds)
|
||||
PingInterval int `json:"PingInterval" example:"5"`
|
||||
// The snapshot interval for edge agent - used in edge async mode (in seconds)
|
||||
SnapshotInterval int `json:"SnapshotInterval" example:"5"`
|
||||
// EdgeAsyncMode enables edge async mode by default
|
||||
AsyncMode bool
|
||||
}
|
||||
|
||||
// Deprecated fields
|
||||
DisplayDonationHeader bool
|
||||
DisplayExternalContributors bool
|
||||
@@ -900,6 +929,8 @@ type (
|
||||
AdditionalFiles []string `json:"AdditionalFiles"`
|
||||
// The auto update settings of a git stack
|
||||
AutoUpdate *StackAutoUpdate `json:"AutoUpdate"`
|
||||
// The stack deployment option
|
||||
Option *StackOption `json:"Option"`
|
||||
// The git config of this stack
|
||||
GitConfig *gittypes.RepoConfig
|
||||
// Whether the stack is from a app template
|
||||
@@ -920,6 +951,12 @@ type (
|
||||
JobID string `example:"15"`
|
||||
}
|
||||
|
||||
// StackOption represents the options for stack deployment
|
||||
StackOption struct {
|
||||
// Prune services that are no longer referenced
|
||||
Prune bool `example:"false"`
|
||||
}
|
||||
|
||||
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
|
||||
StackID int
|
||||
|
||||
@@ -1206,6 +1243,7 @@ type (
|
||||
NormalizeStackName(name string) string
|
||||
Up(ctx context.Context, stack *Stack, endpoint *Endpoint, forceRereate bool) error
|
||||
Down(ctx context.Context, stack *Stack, endpoint *Endpoint) error
|
||||
Pull(ctx context.Context, stack *Stack, endpoint *Endpoint) error
|
||||
}
|
||||
|
||||
// CryptoService represents a service for encrypting/hashing data
|
||||
@@ -1355,7 +1393,7 @@ type (
|
||||
SwarmStackManager interface {
|
||||
Login(registries []Registry, endpoint *Endpoint) error
|
||||
Logout(endpoint *Endpoint) error
|
||||
Deploy(stack *Stack, prune bool, endpoint *Endpoint) error
|
||||
Deploy(stack *Stack, prune bool, pullImage bool, endpoint *Endpoint) error
|
||||
Remove(stack *Stack, endpoint *Endpoint) error
|
||||
NormalizeStackName(name string) string
|
||||
}
|
||||
@@ -1363,9 +1401,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.14.0"
|
||||
APIVersion = "2.15.1"
|
||||
// DBVersion is the version number of the Portainer database
|
||||
DBVersion = 50
|
||||
DBVersion = 61
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
ComposeSyntaxMaxVersion = "3.9"
|
||||
// AssetsServerURL represents the URL of the Portainer asset server
|
||||
|
||||
@@ -89,12 +89,12 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
|
||||
|
||||
switch stack.Type {
|
||||
case portainer.DockerComposeStack:
|
||||
err := deployer.DeployComposeStack(stack, endpoint, registries, false)
|
||||
err := deployer.DeployComposeStack(stack, endpoint, registries, true, false)
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
|
||||
}
|
||||
case portainer.DockerSwarmStack:
|
||||
err := deployer.DeploySwarmStack(stack, endpoint, registries, true)
|
||||
err := deployer.DeploySwarmStack(stack, endpoint, registries, true, true)
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
|
||||
}
|
||||
|
||||
@@ -28,11 +28,11 @@ func (g *gitService) LatestCommitID(repositoryURL, referenceName, username, pass
|
||||
|
||||
type noopDeployer struct{}
|
||||
|
||||
func (s *noopDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error {
|
||||
func (s *noopDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forceRereate bool) error {
|
||||
func (s *noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRereate bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+18
-6
@@ -13,8 +13,8 @@ import (
|
||||
)
|
||||
|
||||
type StackDeployer interface {
|
||||
DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error
|
||||
DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forceRereate bool) error
|
||||
DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error
|
||||
DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRereate bool) error
|
||||
DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error
|
||||
}
|
||||
|
||||
@@ -35,24 +35,36 @@ func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStac
|
||||
}
|
||||
}
|
||||
|
||||
func (d *stackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error {
|
||||
func (d *stackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
d.swarmStackManager.Login(registries, endpoint)
|
||||
defer d.swarmStackManager.Logout(endpoint)
|
||||
|
||||
return d.swarmStackManager.Deploy(stack, prune, endpoint)
|
||||
return d.swarmStackManager.Deploy(stack, prune, pullImage, endpoint)
|
||||
}
|
||||
|
||||
func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forceRereate bool) error {
|
||||
func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRereate bool) error {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
d.swarmStackManager.Login(registries, endpoint)
|
||||
defer d.swarmStackManager.Logout(endpoint)
|
||||
|
||||
return d.composeStackManager.Up(context.TODO(), stack, endpoint, forceRereate)
|
||||
// --force-recreate doesn't pull updated images
|
||||
if forcePullImage {
|
||||
err := d.composeStackManager.Pull(context.TODO(), stack, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := d.composeStackManager.Up(context.TODO(), stack, endpoint, forceRereate)
|
||||
if err != nil {
|
||||
d.composeStackManager.Down(context.TODO(), stack, endpoint)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
|
||||
|
||||
@@ -693,6 +693,12 @@ definitions:
|
||||
$ref: '#/definitions/portainer.DockerSnapshotRaw'
|
||||
DockerVersion:
|
||||
type: string
|
||||
GpuUseAll:
|
||||
type: boolean
|
||||
GpuUseList:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
HealthyContainerCount:
|
||||
type: integer
|
||||
ImageCount:
|
||||
@@ -849,6 +855,11 @@ definitions:
|
||||
EdgeKey:
|
||||
description: The key which is used to map the agent to Portainer
|
||||
type: string
|
||||
Gpus:
|
||||
description: Endpoint Gpus information
|
||||
items:
|
||||
$ref: '#/definitions/portainer.Pair'
|
||||
type: array
|
||||
GroupId:
|
||||
description: Endpoint group identifier
|
||||
example: 1
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export default 'SvgrURL';
|
||||
export const ReactComponent = 'div';
|
||||
@@ -1,3 +1,4 @@
|
||||
<button type="button" ngf-select="$ctrl.onFileSelected($file)" class="btn ng-scope" button-spinner="$ctrl.state.uploadInProgress">
|
||||
<i style="margin: 0" class="fa fa-upload" ng-if="!$ctrl.state.uploadInProgress"></i>
|
||||
<button ng-if="!$ctrl.state.uploadInProgress" type="button" ngf-select="$ctrl.onFileSelected($file)" class="btn btn-light ng-scope">
|
||||
<pr-icon icon="'upload'" feather="true"></pr-icon>
|
||||
</button>
|
||||
<button ng-if="$ctrl.state.uploadInProgress" type="button" class="btn btn-sm btn-light" button-spinner="$ctrl.state.uploadInProgress"></button>
|
||||
|
||||
@@ -1,45 +1,57 @@
|
||||
<div class="datatable">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="{{ $ctrl.titleIcon }}" title-text="{{ $ctrl.titleText }}">
|
||||
<file-uploader authorization="DockerAgentBrowsePut" ng-if="$ctrl.isUploadAllowed" on-file-selected="($ctrl.onFileSelectedForUpload)"> </file-uploader>
|
||||
</rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
/>
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle vertical-center">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="$ctrl.titleIcon" feather="true"></pr-icon>
|
||||
</div>
|
||||
{{ $ctrl.titleText }}
|
||||
</div>
|
||||
<div class="searchBar vertical-center">
|
||||
<pr-icon icon="'search'" feather="true" class-name="'searchIcon'"></pr-icon>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
/>
|
||||
</div>
|
||||
<file-uploader authorization="DockerAgentBrowsePut" ng-if="$ctrl.isUploadAllowed" on-file-selected="($ctrl.onFileSelectedForUpload)"> </file-uploader>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('Name')">
|
||||
Name
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
<table-column-header
|
||||
col-title="'Name'"
|
||||
can-sort="true"
|
||||
is-sorted="$ctrl.state.orderBy === 'Name'"
|
||||
is-sorted-desc="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"
|
||||
ng-click="$ctrl.changeOrderBy('Name')"
|
||||
></table-column-header>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('Size')">
|
||||
Size
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
<table-column-header
|
||||
col-title="'Size'"
|
||||
can-sort="true"
|
||||
is-sorted="$ctrl.state.orderBy === 'Size'"
|
||||
is-sorted-desc="$ctrl.state.orderBy === 'Size' && $ctrl.state.reverseOrder"
|
||||
ng-click="$ctrl.changeOrderBy('Size')"
|
||||
></table-column-header>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('ModTime')">
|
||||
Last modification
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ModTime' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ModTime' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
<table-column-header
|
||||
col-title="'Last modification'"
|
||||
can-sort="true"
|
||||
is-sorted="$ctrl.state.orderBy === 'ModTime'"
|
||||
is-sorted-desc="$ctrl.state.orderBy === 'ModTime' && $ctrl.state.reverseOrder"
|
||||
ng-click="$ctrl.changeOrderBy('ModTime')"
|
||||
></table-column-header>
|
||||
</th>
|
||||
<th> Actions </th>
|
||||
</tr>
|
||||
@@ -47,12 +59,12 @@
|
||||
<tbody>
|
||||
<tr ng-if="!$ctrl.isRoot">
|
||||
<td colspan="4">
|
||||
<a ng-click="$ctrl.goToParent()"><i class="fa fa-level-up-alt space-right"></i>Go to parent</a>
|
||||
<a ng-click="$ctrl.goToParent()"><pr-icon icon="'corner-left-up'" feather="true"></pr-icon>Go to parent</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-repeat="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder))">
|
||||
<td>
|
||||
<span ng-if="item.edit">
|
||||
<span ng-if="item.edit" class="vertical-center">
|
||||
<input
|
||||
class="input-sm"
|
||||
type="text"
|
||||
@@ -60,27 +72,27 @@
|
||||
on-enter-key="$ctrl.rename({ name: item.Name, newName: item.newName }); item.edit = false"
|
||||
auto-focus
|
||||
/>
|
||||
<a class="interactive" ng-click="item.edit = false;"><i class="fa fa-times"></i></a>
|
||||
<a class="interactive" ng-click="$ctrl.rename({name: item.Name, newName: item.newName}); item.edit = false;"><i class="fa fa-check-square"></i></a>
|
||||
<a class="interactive" ng-click="item.edit = false;"><pr-icon icon="'x'" feather="true"></pr-icon></a>
|
||||
<a class="interactive" ng-click="$ctrl.rename({name: item.Name, newName: item.newName}); item.edit = false;"><pr-icon icon="'check'" feather="true"></pr-icon></a>
|
||||
</span>
|
||||
<span ng-if="!item.edit && item.Dir">
|
||||
<a ng-click="$ctrl.browse({name: item.Name})"><i class="fa fa-folder space-right" aria-hidden="true"></i>{{ item.Name }}</a>
|
||||
<a ng-click="$ctrl.browse({name: item.Name})" class="vertical-center"><pr-icon icon="'folder'" feather="true"></pr-icon>{{ item.Name }}</a>
|
||||
</span>
|
||||
<span ng-if="!item.edit && !item.Dir"> <i class="fa fa-file space-right" aria-hidden="true"></i>{{ item.Name }} </span>
|
||||
<span ng-if="!item.edit && !item.Dir" class="vertical-center"><pr-icon icon="'file'" feather="true"></pr-icon>{{ item.Name }}</span>
|
||||
</td>
|
||||
<td>{{ item.Size | humansize }}</td>
|
||||
<td>
|
||||
{{ item.ModTime | getisodatefromtimestamp }}
|
||||
</td>
|
||||
<td>
|
||||
<btn authorization="DockerAgentBrowseGet" class="btn btn-xs btn-primary space-right" ng-click="$ctrl.download({ name: item.Name })" ng-if="!item.Dir">
|
||||
<i class="fa fa-download" aria-hidden="true"></i> Download
|
||||
<btn authorization="DockerAgentBrowseGet" class="btn btn-xs btn-secondary space-right" ng-click="$ctrl.download({ name: item.Name })" ng-if="!item.Dir">
|
||||
<pr-icon icon="'download'" feather="true"></pr-icon> Download
|
||||
</btn>
|
||||
<btn authorization="DockerAgentBrowseRename" class="btn btn-xs btn-primary space-right" ng-click="item.newName = item.Name; item.edit = true">
|
||||
<i class="fa fa-edit" aria-hidden="true"></i> Rename
|
||||
<btn authorization="DockerAgentBrowseRename" class="btn btn-xs btn-secondary space-right" ng-click="item.newName = item.Name; item.edit = true">
|
||||
<pr-icon icon="'edit'" feather="true"></pr-icon> Rename
|
||||
</btn>
|
||||
<btn authorization="DockerAgentBrowseDelete" class="btn btn-xs btn-danger" ng-click="$ctrl.delete({ name: item.Name })">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i> Delete
|
||||
<btn authorization="DockerAgentBrowseDelete" class="btn btn-xs btn-dangerlight" ng-click="$ctrl.delete({ name: item.Name })">
|
||||
<pr-icon icon="'trash-2'" feather="true"></pr-icon> Delete
|
||||
</btn>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<files-datatable
|
||||
title-text="Host browser - {{ $ctrl.getRelativePath() }}"
|
||||
title-icon="fa-file"
|
||||
title-icon="file"
|
||||
dataset="$ctrl.files"
|
||||
table-key="host_browser"
|
||||
order-by="Dir"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<files-datatable
|
||||
title-text="Volume browser"
|
||||
title-icon="fa-file"
|
||||
title-icon="file"
|
||||
dataset="$ctrl.files"
|
||||
table-key="volume_browser"
|
||||
order-by="Dir"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
import { useSettings } from '@/portainer/settings/queries';
|
||||
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||
|
||||
const categories = [
|
||||
'docker',
|
||||
@@ -64,7 +64,9 @@ export function push(
|
||||
}
|
||||
|
||||
export function useAnalytics() {
|
||||
const telemetryQuery = useSettings((settings) => settings.EnableTelemetry);
|
||||
const telemetryQuery = usePublicSettings({
|
||||
select: (settings) => settings.EnableTelemetry,
|
||||
});
|
||||
|
||||
return { trackEvent: handleTrackEvent };
|
||||
|
||||
|
||||
+8
-1
@@ -6,7 +6,14 @@ export function onStartupAngular($rootScope, $state, $interval, LocalStorage, En
|
||||
EndpointProvider.initialize();
|
||||
|
||||
$rootScope.$state = $state;
|
||||
$rootScope.defaultTitle = document.title;
|
||||
const defaultTitle = document.title;
|
||||
|
||||
$transitions.onEnter({}, () => {
|
||||
const endpoint = EndpointProvider.currentEndpoint();
|
||||
if (endpoint) {
|
||||
document.title = `${defaultTitle} | ${endpoint.Name}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Workaround to prevent the loading bar from going backward
|
||||
// https://github.com/chieffancypants/angular-loading-bar/issues/273
|
||||
|
||||
+89
-43
@@ -2,8 +2,27 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('../fonts/Inter-VariableFont.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
}
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
select {
|
||||
font-family: Inter, Arial, Helvetica, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-body-color);
|
||||
font-family: 'Inter';
|
||||
color: var(--text-body-color) !important;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -21,15 +40,16 @@ body,
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.white-space-normal {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: inline;
|
||||
max-width: 155px;
|
||||
max-height: 55px;
|
||||
}
|
||||
|
||||
.white-space-normal {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.legend .title {
|
||||
padding: 0 0.3em;
|
||||
margin: 0.5em;
|
||||
@@ -62,16 +82,16 @@ body,
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header_title_content {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
border-bottom: 1px solid var(--border-form-section-title-color);
|
||||
@apply text-gray-9;
|
||||
@apply th-dark:text-gray-5;
|
||||
@apply th-highcontrast:text-white;
|
||||
|
||||
margin-top: 5px;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-form-section-title-color);
|
||||
margin-bottom: 10px;
|
||||
padding-left: 0;
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form-horizontal .control-label.text-left {
|
||||
@@ -97,10 +117,6 @@ input[type='checkbox'] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
a[ng-click] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.space-right {
|
||||
margin-right: 5px;
|
||||
}
|
||||
@@ -131,25 +147,12 @@ a[ng-click] {
|
||||
background-color: var(--bg-item-highlighted-null-color);
|
||||
}
|
||||
|
||||
.service-datatable {
|
||||
background-color: var(--bg-item-highlighted-color);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.service-datatable thead {
|
||||
background-color: var(--bg-service-datatable-thead) !important;
|
||||
}
|
||||
|
||||
.service-datatable tbody {
|
||||
background-color: var(--bg-service-datatable-tbody);
|
||||
}
|
||||
|
||||
.fa.green-icon {
|
||||
color: #23ae89;
|
||||
}
|
||||
|
||||
.fa.red-icon {
|
||||
color: #ae2323;
|
||||
color: #f04438;
|
||||
}
|
||||
|
||||
.fa.orange-icon {
|
||||
@@ -221,12 +224,12 @@ a[ng-click] {
|
||||
}
|
||||
|
||||
.blocklist-item {
|
||||
padding: 7px;
|
||||
margin-bottom: 7px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-blocklist-color);
|
||||
border-radius: 2px;
|
||||
box-shadow: var(--shadow-box-color);
|
||||
border: 1px solid var(--border-blocklist);
|
||||
border-radius: 8px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.blocklist-item--disabled {
|
||||
@@ -241,6 +244,8 @@ a[ng-click] {
|
||||
}
|
||||
|
||||
.blocklist-item:hover {
|
||||
@apply border border-blue-7;
|
||||
|
||||
background-color: var(--bg-blocklist-hover-color);
|
||||
color: var(--text-blocklist-hover-color);
|
||||
}
|
||||
@@ -261,7 +266,6 @@ a[ng-click] {
|
||||
.blocklist-item-logo {
|
||||
width: 100%;
|
||||
max-width: 60px;
|
||||
height: 100%;
|
||||
max-height: 60px;
|
||||
}
|
||||
|
||||
@@ -379,12 +383,13 @@ a[ng-click] {
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding-top: 30px;
|
||||
background-color: var(--white-color) fff;
|
||||
padding: 20px 25px;
|
||||
background-color: var(--white-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.user-box {
|
||||
margin-right: 25px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.select-endpoint {
|
||||
@@ -472,7 +477,7 @@ a[ng-click] {
|
||||
|
||||
:root[theme='dark'] .bootbox-checkbox-list,
|
||||
:root[theme='highcontrast'] .bootbox-checkbox-list {
|
||||
background-color: var(--black-color);
|
||||
background-color: var(--bg-modal-content-color);
|
||||
}
|
||||
|
||||
.small-select {
|
||||
@@ -807,11 +812,12 @@ json-tree .branch-preview {
|
||||
}
|
||||
/* !spinkit override */
|
||||
|
||||
.kubectl-shell {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding-bottom: 5px;
|
||||
/* uib-typeahead override */
|
||||
#scrollable-dropdown-menu .dropdown-menu {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* !uib-typeahead override */
|
||||
|
||||
.no-margin {
|
||||
margin: 0 !important;
|
||||
@@ -835,3 +841,43 @@ json-tree .branch-preview {
|
||||
.form-check.radio {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.inline-text {
|
||||
display: inline;
|
||||
position: absolute;
|
||||
font-family: 'Montserrat';
|
||||
font-size: smaller;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.web-editor {
|
||||
background-color: var(--bg-webeditor-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.web-editor a {
|
||||
color: var(--text-link-color);
|
||||
}
|
||||
|
||||
.web-editor a:hover {
|
||||
color: var(--text-link-hover-color);
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
reach-portal > div {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
input[style*='background-image: url("data:image/png'] + [data-cy='auth-passwordInputToggle'] {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
input[style*='background-image: url("data:image/png'] {
|
||||
padding-right: 60px;
|
||||
}
|
||||
|
||||
.web-editor .trancluded-item:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
+409
@@ -0,0 +1,409 @@
|
||||
/* Label, Section Title */
|
||||
.label {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.label-success {
|
||||
background-color: var(--ui-success-7);
|
||||
}
|
||||
|
||||
.label-danger {
|
||||
background-color: var(--ui-error-6);
|
||||
}
|
||||
|
||||
.control-label {
|
||||
@apply inline-flex items-center;
|
||||
@apply font-medium;
|
||||
@apply text-gray-7;
|
||||
@apply th-dark:text-gray-warm-3;
|
||||
@apply th-highcontrast:text-white;
|
||||
}
|
||||
|
||||
.vertical-center {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.blue {
|
||||
background: var(--bg-dashboard-item) !important;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Input Group Addon */
|
||||
.input-group-addon:first-child {
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
|
||||
.input-group .form-control:not(:first-child):not(:last-child) {
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
|
||||
.input-group-btn:last-child .btn {
|
||||
margin-left: 5px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 42px;
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.switch input[type='checkbox']:disabled + .slider {
|
||||
background-color: var(--ui-gray-3);
|
||||
@apply th-dark:before:bg-gray-warm-8;
|
||||
@apply th-highcontrast:before:bg-gray-warm-8;
|
||||
@apply th-dark:bg-gray-warm-9;
|
||||
@apply th-highcontrast:bg-gray-warm-9;
|
||||
}
|
||||
|
||||
.switch-values {
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--bg-switch-box-color);
|
||||
-webkit-transition: 0.4s;
|
||||
transition: 0.4s;
|
||||
@apply th-dark:bg-gray-warm-9;
|
||||
@apply th-highcontrast:bg-gray-warm-9;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 19px;
|
||||
width: 19px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: var(--white-color);
|
||||
-webkit-transition: 0.4s;
|
||||
transition: 0.4s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--ui-blue-8);
|
||||
@apply th-dark:bg-blue-9;
|
||||
@apply th-highcontrast:bg-blue-9;
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px var(--ui-blue-8);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
-webkit-transform: translateX(17px);
|
||||
-ms-transform: translateX(17px);
|
||||
transform: translateX(17px);
|
||||
}
|
||||
|
||||
.slider.round {
|
||||
border-radius: 25px;
|
||||
}
|
||||
|
||||
.slider.round:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
|
||||
.md-checkbox input[type='checkbox']:enabled + label:before {
|
||||
background-color: var(--bg-checkbox) !important;
|
||||
border: 1px solid var(--border-checkbox) !important;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.md-checkbox input[type='checkbox']:disabled + label:before {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.md-checkbox input[type='checkbox']:checked + label:before {
|
||||
background-color: var(--ui-blue-8) !important;
|
||||
color: var(--ui-blue-8) !important;
|
||||
border: 1px solid var(--ui-blue-8) !important;
|
||||
}
|
||||
|
||||
.md-checkbox input[type='checkbox']:checked + .checkmark {
|
||||
border-color: var(--grey-6);
|
||||
background-color: var(--bg-checkbox);
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
|
||||
.rzslider .rz-pointer {
|
||||
background-color: var(--white-color);
|
||||
border: 3px solid var(--ui-blue-8);
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
.rzslider .rz-bar {
|
||||
background-color: var(--ui-gray-5);
|
||||
height: 8px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.rzslider .rz-selection {
|
||||
background-color: var(--ui-blue-8);
|
||||
}
|
||||
|
||||
.rzslider .rz-pointer:after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Widget */
|
||||
|
||||
.widget .widget-icon {
|
||||
@apply text-lg !p-2 mr-1;
|
||||
@apply bg-blue-3 text-blue-8;
|
||||
@apply th-dark:bg-gray-9 th-dark:text-blue-3;
|
||||
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5%;
|
||||
}
|
||||
|
||||
.widget .widget-body table thead {
|
||||
border-top: 1px solid var(--border-table-color);
|
||||
}
|
||||
|
||||
/* Toaster */
|
||||
|
||||
#toast-container > .toast-success {
|
||||
background-image: url(../images/icon-success.svg) !important;
|
||||
background-size: 40px 40px;
|
||||
background-position: top 12px left 12px;
|
||||
}
|
||||
|
||||
#toast-container > .toast-error {
|
||||
background-image: url(../images/icon-error.svg) !important;
|
||||
background-size: 40px 40px;
|
||||
background-position: top 12px left 12px;
|
||||
}
|
||||
|
||||
#toast-container > .toast-warning {
|
||||
background-image: url(../images/icon-warning.svg) !important;
|
||||
background-size: 40px 40px;
|
||||
background-position: top 12px left 12px;
|
||||
}
|
||||
|
||||
.toast-success .toast-progress {
|
||||
background-color: var(--ui-success-7);
|
||||
}
|
||||
.toast-warning .toast-progress {
|
||||
background-color: var(--ui-warning-6);
|
||||
}
|
||||
.toast-error .toast-progress {
|
||||
background-color: var(--ui-error-8);
|
||||
}
|
||||
|
||||
#toast-container > div {
|
||||
color: var(--ui-gray-7);
|
||||
background-color: var(--white-color);
|
||||
border-radius: 8px;
|
||||
padding: 18px 20px 18px 68px;
|
||||
width: 300px;
|
||||
opacity: 1;
|
||||
-ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=100);
|
||||
filter: alpha(opacity=100);
|
||||
}
|
||||
|
||||
#toast-container > div:hover {
|
||||
-moz-box-shadow: 0 0 12px var(--ui-gray-7);
|
||||
-webkit-box-shadow: 0 0 12px var(--ui-gray-7);
|
||||
box-shadow: 0 0 12px var(--ui-gray-7);
|
||||
}
|
||||
|
||||
.toast-close-button {
|
||||
color: var(--black-color);
|
||||
text-decoration: none;
|
||||
margin-top: 5px;
|
||||
cursor: pointer;
|
||||
opacity: 0.4;
|
||||
-ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=40);
|
||||
filter: alpha(opacity=40);
|
||||
}
|
||||
|
||||
.toast-close-button:hover,
|
||||
.toast-close-button:focus {
|
||||
color: var(--black-color);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
-ms-filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=60);
|
||||
filter: alpha(opacity=60);
|
||||
}
|
||||
|
||||
.toast-title {
|
||||
font-weight: 500;
|
||||
color: var(--black-color);
|
||||
padding-right: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
|
||||
.modal-dialog {
|
||||
width: 450px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.background-error {
|
||||
padding-top: 55px;
|
||||
background-image: url(../images/icon-error.svg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: top left;
|
||||
}
|
||||
|
||||
.background-warning {
|
||||
padding-top: 55px;
|
||||
background-image: url(../images/icon-warning.svg);
|
||||
background-repeat: no-repeat;
|
||||
background-position: top left;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
margin-bottom: 10px;
|
||||
padding: 0px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.modal-header .close {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.modal-header .modal-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 10px 0px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.modal-body .bootbox-body {
|
||||
font-size: 12px;
|
||||
color: var(--text-bootbox);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 10px 0px;
|
||||
border-top: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-footer .bootbox-cancel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modal-footer .bootbox-accept {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bootbox-checkbox-list {
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
/* Status Indicator Inside Table Section Label Style */
|
||||
.table .label {
|
||||
border-radius: 8px !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table .label .label-danger {
|
||||
background-color: var(--ui-error-8);
|
||||
}
|
||||
|
||||
.table .label .label-warn {
|
||||
background-color: var(--ui-warning-8);
|
||||
}
|
||||
|
||||
.table .label .label-success {
|
||||
background-color: var(--ui-success-7);
|
||||
}
|
||||
|
||||
/* Required Label with asterisk */
|
||||
|
||||
.required:after {
|
||||
content: '*';
|
||||
color: var(--ui-error-9);
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
width: 50%;
|
||||
display: inline-block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.progress .progress-bar {
|
||||
background-color: var(--ui-blue-8);
|
||||
}
|
||||
|
||||
.progress + span {
|
||||
display: inline-block;
|
||||
font-size: 85%;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.datatable .footer .paginationControls .pagination {
|
||||
border: 1px solid var(--border-pagination-color);
|
||||
}
|
||||
|
||||
.pagination li button {
|
||||
color: var(--ui-gray-9) !important;
|
||||
}
|
||||
|
||||
.pagination li:active button,
|
||||
.pagination li:focus button {
|
||||
border: 1px solid var(--ui-gray-5) !important;
|
||||
}
|
||||
|
||||
.pagination li a {
|
||||
text-decoration: none !important;
|
||||
cursor: pointer;
|
||||
color: var(--ui-gray-9) !important;
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
.btn {
|
||||
@apply !outline-none;
|
||||
@apply border border-solid border-transparent;
|
||||
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn.disabled,
|
||||
.btn[disabled],
|
||||
fieldset[disabled] .btn {
|
||||
@apply opacity-40;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
color: var(--text-button-hover-color);
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn.btn-primary {
|
||||
@apply text-white bg-blue-8 border-blue-8;
|
||||
@apply hover:text-white hover:bg-blue-9 hover:border-blue-9;
|
||||
@apply th-dark:hover:bg-blue-7 th-dark:hover:border-blue-7;
|
||||
}
|
||||
|
||||
.btn.btn-primary:active,
|
||||
.btn.btn-primary.active,
|
||||
.open > .dropdown-toggle.btn-primary {
|
||||
@apply bg-blue-9 border-blue-5;
|
||||
}
|
||||
|
||||
.nav-pills > li.active > a,
|
||||
.nav-pills > li.active > a:hover,
|
||||
.nav-pills > li.active > a:focus {
|
||||
@apply bg-blue-8;
|
||||
}
|
||||
|
||||
/* Button Secondary */
|
||||
.btn.btn-secondary {
|
||||
@apply border border-solid;
|
||||
|
||||
@apply text-blue-9 bg-blue-2 border-blue-8;
|
||||
@apply hover:bg-blue-3;
|
||||
|
||||
@apply th-dark:text-blue-3 th-dark:bg-gray-10 th-dark:border-blue-7;
|
||||
@apply th-dark:hover:bg-blue-11;
|
||||
}
|
||||
|
||||
.btn.btn-danger {
|
||||
@apply bg-error-8 border-error-8;
|
||||
@apply hover:bg-error-7 hover:border-error-7 hover:text-white;
|
||||
}
|
||||
|
||||
.btn.btn-danger:active,
|
||||
.btn.btn-danger.active,
|
||||
.open > .dropdown-toggle.btn-danger {
|
||||
@apply bg-error-8 text-white border-blue-5;
|
||||
}
|
||||
|
||||
.btn.btn-dangerlight {
|
||||
@apply text-error-9 th-dark:text-white;
|
||||
@apply bg-error-3 th-dark:bg-error-9;
|
||||
@apply hover:bg-error-2 th-dark:hover:bg-error-11;
|
||||
@apply border-error-5 th-dark:border-error-7 th-highcontrast:border-error-7;
|
||||
@apply border border-solid;
|
||||
}
|
||||
|
||||
.btn.btn-success {
|
||||
background-color: var(--ui-success-7);
|
||||
}
|
||||
|
||||
.btn.btn-success:hover {
|
||||
color: var(--white-color);
|
||||
}
|
||||
|
||||
/* secondary-grey */
|
||||
.btn.btn-default,
|
||||
.btn.btn-light {
|
||||
@apply bg-white border-gray-5 text-gray-9;
|
||||
@apply hover:bg-gray-3 hover:border-gray-5 hover:text-gray-10;
|
||||
|
||||
/* dark mode */
|
||||
@apply th-dark:bg-gray-iron-10 th-dark:border-gray-warm-7 th-dark:text-gray-warm-4;
|
||||
@apply th-dark:hover:bg-gray-iron-9 th-dark:hover:border-gray-6 th-dark:hover:text-gray-warm-4;
|
||||
|
||||
@apply th-highcontrast:bg-black th-highcontrast:border-gray-2 th-highcontrast:text-white;
|
||||
@apply th-highcontrast:hover:bg-gray-9 th-highcontrast:hover:border-gray-6 th-highcontrast:hover:text-gray-warm-4;
|
||||
}
|
||||
|
||||
.btn.btn-light:active,
|
||||
.btn.btn-light.active,
|
||||
.open > .dropdown-toggle.btn-light {
|
||||
background-color: var(--ui-gray-3);
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
@apply text-blue-8 hover:text-blue-9 disabled:text-gray-5;
|
||||
@apply th-dark:text-blue-8 th-dark:hover:text-blue-7;
|
||||
@apply th-highcontrast:text-blue-8 th-highcontrast:hover:text-blue-7;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.input-group-btn .btn.active,
|
||||
.btn-group .btn.active {
|
||||
@apply bg-blue-2 text-blue-10 border-blue-5;
|
||||
@apply th-dark:bg-blue-11 th-dark:text-blue-2 th-dark:border-blue-9;
|
||||
}
|
||||
|
||||
/* focus */
|
||||
|
||||
.btn.btn-primary:focus,
|
||||
.btn.btn-secondary:focus,
|
||||
.btn.btn-light:focus {
|
||||
@apply border-blue-5;
|
||||
}
|
||||
|
||||
.btn.btn-danger:focus,
|
||||
.btn.btn-dangerlight:focus {
|
||||
@apply border-blue-6;
|
||||
}
|
||||
|
||||
.btn.btn-primary:focus,
|
||||
.btn.btn-secondary:focus,
|
||||
.btn.btn-light:focus,
|
||||
.btn.btn-danger:focus,
|
||||
.btn.btn-dangerlight:focus {
|
||||
--btn-focus-color: var(--ui-blue-3);
|
||||
box-shadow: 0px 0px 0px 4px var(--btn-focus-color);
|
||||
}
|
||||
|
||||
[theme='dark'] .btn.btn-primary:focus,
|
||||
[theme='dark'] .btn.btn-secondary:focus,
|
||||
[theme='dark'] .btn.btn-light:focus,
|
||||
[theme='dark'] .btn.btn-danger:focus,
|
||||
[theme='dark'] .btn.btn-dangerlight:focus {
|
||||
--btn-focus-color: var(--ui-blue-11);
|
||||
}
|
||||
|
||||
a.no-link,
|
||||
a[ng-click] {
|
||||
@apply text-current;
|
||||
@apply hover:no-underline hover:text-current;
|
||||
@apply focus:no-underline focus:text-current;
|
||||
}
|
||||
|
||||
a,
|
||||
a.hyperlink {
|
||||
@apply text-blue-8 hover:text-blue-9;
|
||||
@apply hover:underline cursor-pointer;
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
{
|
||||
"black": "#000000",
|
||||
"white": "#ffffff",
|
||||
"gray": {
|
||||
"1": "#fcfcfd",
|
||||
"2": "#f9fafb",
|
||||
"3": "#f2f4f7",
|
||||
"4": "#eaecf0",
|
||||
"5": "#d0d5dd",
|
||||
"6": "#98a2b3",
|
||||
"7": "#667085",
|
||||
"8": "#475467",
|
||||
"9": "#344054",
|
||||
"10": "#1d2939",
|
||||
"11": "#101828"
|
||||
},
|
||||
"blue": {
|
||||
"1": "#f5fbff",
|
||||
"2": "#f0f9ff",
|
||||
"3": "#e0f2fe",
|
||||
"4": "#b9e6fe",
|
||||
"5": "#7cd4fd",
|
||||
"6": "#36bffa",
|
||||
"7": "#0ba5ec",
|
||||
"8": "#0086c9",
|
||||
"9": "#026aa2",
|
||||
"10": "#065986",
|
||||
"11": "#0b4a6f"
|
||||
},
|
||||
"error": {
|
||||
"1": "#fffbfa",
|
||||
"2": "#fef3f2",
|
||||
"3": "#fee4e2",
|
||||
"4": "#fecdca",
|
||||
"5": "#fda29b",
|
||||
"6": "#f97066",
|
||||
"7": "#f04438",
|
||||
"8": "#d92d20",
|
||||
"9": "#b42318",
|
||||
"10": "#912018",
|
||||
"11": "#7a271a"
|
||||
},
|
||||
"warning": {
|
||||
"1": "#fffcf5",
|
||||
"2": "#fffaeb",
|
||||
"3": "#fef0c7",
|
||||
"4": "#fedf89",
|
||||
"5": "#fec84b",
|
||||
"6": "#fdb022",
|
||||
"7": "#f79009",
|
||||
"8": "#dc6803",
|
||||
"9": "#b54708",
|
||||
"10": "#93370d",
|
||||
"11": "#7a2e0e"
|
||||
},
|
||||
"success": {
|
||||
"1": "#f6fef9",
|
||||
"2": "#ecfdf3",
|
||||
"3": "#d1fadf",
|
||||
"4": "#a6f4c5",
|
||||
"5": "#6ce9a6",
|
||||
"6": "#32d583",
|
||||
"7": "#12b76a",
|
||||
"8": "#039855",
|
||||
"9": "#027a48",
|
||||
"10": "#05603a",
|
||||
"11": "#054f31"
|
||||
},
|
||||
"gray-blue": {
|
||||
"1": "#fcfcfd",
|
||||
"2": "#f8f9fc",
|
||||
"3": "#eaecf5",
|
||||
"4": "#d5d9eb",
|
||||
"5": "#b3b8db",
|
||||
"6": "#717bbc",
|
||||
"7": "#4e5ba6",
|
||||
"8": "#3e4784",
|
||||
"9": "#363f72",
|
||||
"10": "#293056",
|
||||
"11": "#293056"
|
||||
},
|
||||
"gray-cool": {
|
||||
"1": "#fcfcfd",
|
||||
"2": "#f9f9fb",
|
||||
"3": "#eff1f5",
|
||||
"4": "#dcdfea",
|
||||
"5": "#b9c0d4",
|
||||
"6": "#7d89b0",
|
||||
"7": "#5d6b98",
|
||||
"8": "#4a5578",
|
||||
"9": "#404968",
|
||||
"10": "#30374f",
|
||||
"11": "#111322"
|
||||
},
|
||||
"gray-modern": {
|
||||
"1": "#fcfcfd",
|
||||
"2": "#f8fafc",
|
||||
"3": "#eef2f6",
|
||||
"4": "#e3e8ef",
|
||||
"5": "#cdd5df",
|
||||
"6": "#9aa4b2",
|
||||
"7": "#697586",
|
||||
"8": "#4b5565",
|
||||
"9": "#364152",
|
||||
"10": "#202939",
|
||||
"11": "#121926"
|
||||
},
|
||||
"gray-neutral": {
|
||||
"1": "#fcfcfd",
|
||||
"2": "#f9fafb",
|
||||
"3": "#f3f4f6",
|
||||
"4": "#e5e7eb",
|
||||
"5": "#d2d6db",
|
||||
"6": "#9da4ae",
|
||||
"7": "#6c737f",
|
||||
"8": "#4d5761",
|
||||
"9": "#384250",
|
||||
"10": "#1f2a37",
|
||||
"11": "#111927"
|
||||
},
|
||||
"gray-iron": {
|
||||
"1": "#fcfcfc",
|
||||
"2": "#fafafa",
|
||||
"3": "#f4f4f5",
|
||||
"4": "#e4e4e7",
|
||||
"5": "#d1d1d6",
|
||||
"6": "#d1d1d6",
|
||||
"7": "#70707b",
|
||||
"8": "#51525c",
|
||||
"9": "#3f3f46",
|
||||
"10": "#26272b",
|
||||
"11": "#18181b"
|
||||
},
|
||||
"gray-true": {
|
||||
"1": "#fcfcfc",
|
||||
"2": "#fafafa",
|
||||
"3": "#f5f5f5",
|
||||
"4": "#e5e5e5",
|
||||
"5": "#d6d6d6",
|
||||
"6": "#a3a3a3",
|
||||
"7": "#737373",
|
||||
"8": "#525252",
|
||||
"9": "#424242",
|
||||
"10": "#292929",
|
||||
"11": "#141414"
|
||||
},
|
||||
"gray-warm": {
|
||||
"1": "#fdfdfc",
|
||||
"2": "#fafaf9",
|
||||
"3": "#f5f5f4",
|
||||
"4": "#e7e5e4",
|
||||
"5": "#d7d3d0",
|
||||
"6": "#a9a29d",
|
||||
"7": "#79716b",
|
||||
"8": "#57534e",
|
||||
"9": "#44403c",
|
||||
"10": "#292524",
|
||||
"11": "#1c1917"
|
||||
},
|
||||
"moss": {
|
||||
"1": "#fafdf7",
|
||||
"2": "#f5fbee",
|
||||
"3": "#e6f4d7",
|
||||
"4": "#ceeab0",
|
||||
"5": "#acdc79",
|
||||
"6": "#86cb3c",
|
||||
"7": "#669f2a",
|
||||
"8": "#4f7a21",
|
||||
"9": "#3f621a",
|
||||
"10": "#335015",
|
||||
"11": "#2b4212"
|
||||
},
|
||||
"green-light": {
|
||||
"1": "#fafef5",
|
||||
"2": "#f3fee7",
|
||||
"3": "#e4fbcc",
|
||||
"4": "#d0f8ab",
|
||||
"5": "#a6ef67",
|
||||
"6": "#85e13a",
|
||||
"7": "#66c61c",
|
||||
"8": "#4ca30d",
|
||||
"9": "#3b7c0f",
|
||||
"10": "#326212",
|
||||
"11": "#2b5314"
|
||||
},
|
||||
"green": {
|
||||
"1": "#f6fef9",
|
||||
"2": "#edfcf2",
|
||||
"3": "#d3f8df",
|
||||
"4": "#aaf0c4",
|
||||
"5": "#73e2a3",
|
||||
"6": "#73e2a3",
|
||||
"7": "#16b364",
|
||||
"8": "#099250",
|
||||
"9": "#087443",
|
||||
"10": "#095c37",
|
||||
"11": "#084c2e"
|
||||
},
|
||||
"teal": {
|
||||
"1": "#f6fefc",
|
||||
"2": "#f0fdf9",
|
||||
"3": "#ccfbef",
|
||||
"4": "#99f6e0",
|
||||
"5": "#5fe9d0",
|
||||
"6": "#2ed3b7",
|
||||
"7": "#15b79e",
|
||||
"8": "#0e9384",
|
||||
"9": "#107569",
|
||||
"10": "#125d56",
|
||||
"11": "#134e48"
|
||||
},
|
||||
"cyan": {
|
||||
"1": "#f5feff",
|
||||
"2": "#ecfdff",
|
||||
"3": "#cff9fe",
|
||||
"4": "#a5f0fc",
|
||||
"5": "#67e3f9",
|
||||
"6": "#22ccee",
|
||||
"7": "#06aed4",
|
||||
"8": "#088ab2",
|
||||
"9": "#0e7090",
|
||||
"10": "#155b75",
|
||||
"11": "#164c63"
|
||||
},
|
||||
"blue-dark": {
|
||||
"1": "#f5f8ff",
|
||||
"2": "#eff4ff",
|
||||
"3": "#d1e0ff",
|
||||
"4": "#b2ccff",
|
||||
"5": "#84adff",
|
||||
"6": "#528bff",
|
||||
"7": "#2970ff",
|
||||
"8": "#155eef",
|
||||
"9": "#004eeb",
|
||||
"10": "#0040c1",
|
||||
"11": "#00359e"
|
||||
},
|
||||
"indigo": {
|
||||
"1": "#f5f8ff",
|
||||
"2": "#eef4ff",
|
||||
"3": "#e0eaff",
|
||||
"4": "#c7d7fe",
|
||||
"5": "#a4bcfd",
|
||||
"6": "#8098f9",
|
||||
"7": "#8098f9",
|
||||
"8": "#444ce7",
|
||||
"9": "#3538cd",
|
||||
"10": "#2d31a6",
|
||||
"11": "#2d3282"
|
||||
},
|
||||
"violet": {
|
||||
"1": "#fbfaff",
|
||||
"2": "#f5f3ff",
|
||||
"3": "#ece9fe",
|
||||
"4": "#ddd6fe",
|
||||
"5": "#c3b5fd",
|
||||
"6": "#a48afb",
|
||||
"7": "#875bf7",
|
||||
"8": "#7839ee",
|
||||
"9": "#6927da",
|
||||
"10": "#5720b7",
|
||||
"11": "#491c96"
|
||||
},
|
||||
"purple": {
|
||||
"1": "#fafaff",
|
||||
"2": "#f4f3ff",
|
||||
"3": "#ebe9fe",
|
||||
"4": "#d9d6fe",
|
||||
"5": "#bdb4fe",
|
||||
"6": "#9b8afb",
|
||||
"7": "#7a5af8",
|
||||
"8": "#6938ef",
|
||||
"9": "#5925dc",
|
||||
"10": "#4a1fb8",
|
||||
"11": "#3e1c96"
|
||||
},
|
||||
"fuchsia": {
|
||||
"1": "#fefaff",
|
||||
"2": "#fdf4ff",
|
||||
"3": "#fbe8ff",
|
||||
"4": "#f6d0fe",
|
||||
"5": "#eeaafd",
|
||||
"6": "#e478fa",
|
||||
"7": "#d444f1",
|
||||
"8": "#ba24d5",
|
||||
"9": "#9f1ab1",
|
||||
"10": "#821890",
|
||||
"11": "#6f1877"
|
||||
},
|
||||
"pink": {
|
||||
"1": "#fef6fb",
|
||||
"2": "#fdf2fa",
|
||||
"3": "#fce7f6",
|
||||
"4": "#fce7f6",
|
||||
"5": "#faa7e0",
|
||||
"6": "#f670c7",
|
||||
"7": "#ee46bc",
|
||||
"8": "#dd2590",
|
||||
"9": "#c11574",
|
||||
"10": "#9e165f",
|
||||
"11": "#851651"
|
||||
},
|
||||
"rose": {
|
||||
"1": "#fff5f6",
|
||||
"2": "#fff1f3",
|
||||
"3": "#ffe4e8",
|
||||
"4": "#fecdd6",
|
||||
"5": "#fea3b4",
|
||||
"6": "#fd6f8e",
|
||||
"7": "#f63d68",
|
||||
"8": "#e31b54",
|
||||
"9": "#c01048",
|
||||
"10": "#a11043",
|
||||
"11": "#89123e"
|
||||
},
|
||||
"orange-dark": {
|
||||
"1": "#fff9f5",
|
||||
"2": "#fff4ed",
|
||||
"3": "#ffe6d5",
|
||||
"4": "#ffd6ae",
|
||||
"5": "#ff9c66",
|
||||
"6": "#ff692e",
|
||||
"7": "#ff4405",
|
||||
"8": "#e62e05",
|
||||
"9": "#bc1b06",
|
||||
"10": "#97180c",
|
||||
"11": "#771a0d"
|
||||
},
|
||||
"orange": {
|
||||
"1": "#fefaf5",
|
||||
"2": "#fef6ee",
|
||||
"3": "#fdead7",
|
||||
"4": "#f9dbaf",
|
||||
"5": "#f7b27a",
|
||||
"6": "#f38744",
|
||||
"7": "#ef6820",
|
||||
"8": "#e04f16",
|
||||
"9": "#b93815",
|
||||
"10": "#932f19",
|
||||
"11": "#772917"
|
||||
},
|
||||
"yellow": {
|
||||
"1": "#fefdf0",
|
||||
"2": "#fefbe8",
|
||||
"3": "#fef7c3",
|
||||
"4": "#feee95",
|
||||
"5": "#feee95",
|
||||
"6": "#fac515",
|
||||
"7": "#eaaa08",
|
||||
"8": "#ca8504",
|
||||
"9": "#a15c07",
|
||||
"10": "#854a0e",
|
||||
"11": "#713b12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import colors from './colors.json';
|
||||
|
||||
const element = document.createElement('style');
|
||||
|
||||
element.innerHTML = `:root {
|
||||
${Object.entries(colors)
|
||||
.map(([color, hex]) => {
|
||||
if (typeof hex === 'string') {
|
||||
return `--ui-${color}: ${hex}`;
|
||||
}
|
||||
|
||||
return Object.entries(hex)
|
||||
.map(([key, value]) => `--ui-${color}-${key}: ${value}`)
|
||||
.join(';\n');
|
||||
})
|
||||
.join(';\n')}
|
||||
}`;
|
||||
|
||||
document.head.prepend(element);
|
||||
@@ -0,0 +1,133 @@
|
||||
.feather {
|
||||
display: block;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
pr-icon {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: currentColor;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
|
||||
font-size: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
width: var(--icon-size);
|
||||
|
||||
--icon-size: 1em;
|
||||
}
|
||||
|
||||
.icon-xs {
|
||||
--icon-size: 10px;
|
||||
}
|
||||
|
||||
.icon-sm {
|
||||
--icon-size: 14px;
|
||||
}
|
||||
|
||||
.icon-md {
|
||||
--icon-size: 16px;
|
||||
}
|
||||
|
||||
.icon-lg {
|
||||
--icon-size: 22px;
|
||||
}
|
||||
|
||||
.icon-xl {
|
||||
--icon-size: 26px;
|
||||
}
|
||||
|
||||
.icon.icon-alt {
|
||||
fill: var(--black-color);
|
||||
stroke: var(--white-color);
|
||||
}
|
||||
|
||||
.icon-primary,
|
||||
.icon-blue {
|
||||
color: var(--ui-blue-8);
|
||||
}
|
||||
|
||||
.icon.icon-primary-alt {
|
||||
fill: var(--ui-blue-8);
|
||||
stroke: var(--white-color);
|
||||
}
|
||||
|
||||
.icon-secondary {
|
||||
color: var(--ui-gray-8);
|
||||
}
|
||||
|
||||
.icon.icon-secondary-alt {
|
||||
fill: var(--ui-gray-8);
|
||||
stroke: var(--black-color);
|
||||
}
|
||||
|
||||
.icon-warning,
|
||||
.icon-orange {
|
||||
color: var(--ui-warning-8);
|
||||
}
|
||||
|
||||
.icon.icon-warning-alt {
|
||||
fill: var(--ui-warning-8);
|
||||
stroke: var(--white-color);
|
||||
}
|
||||
|
||||
.icon-danger {
|
||||
color: var(--ui-error-9);
|
||||
}
|
||||
|
||||
.icon.icon-danger-alt {
|
||||
fill: var(--ui-error-9);
|
||||
stroke: var(--white-color);
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
color: var(--ui-success-6);
|
||||
}
|
||||
|
||||
.icon.icon-success-alt {
|
||||
fill: var(--ui-success-8);
|
||||
stroke: var(--white-color);
|
||||
}
|
||||
|
||||
.icon-badge {
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5%;
|
||||
}
|
||||
|
||||
.icon-nested-blue {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background-color: var(--ui-blue-3);
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.icon-nested-blue > svg {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-only-icon {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.btn-only-icon pr-icon {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -15,8 +15,14 @@ import 'angular-multiselect/isteven-multi-select.css';
|
||||
import 'spinkit/spinkit.min.css';
|
||||
import '@reach/menu-button/styles.css';
|
||||
|
||||
import './colors';
|
||||
|
||||
import './rdash.css';
|
||||
import './app.css';
|
||||
|
||||
import './theme.css';
|
||||
import './vendor-override.css';
|
||||
import '../fonts/nomad-icon.css';
|
||||
import './bootstrap-override.css';
|
||||
import './icon.css';
|
||||
import './button.css';
|
||||
|
||||
+10
-111
@@ -4,49 +4,6 @@
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
@media only screen and (min-width: 561px) {
|
||||
#page-wrapper.open {
|
||||
padding-left: 250px;
|
||||
}
|
||||
}
|
||||
@media only screen and (max-width: 560px) {
|
||||
#page-wrapper.open {
|
||||
padding-left: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hamburg Menu
|
||||
* When the class of 'hamburg' is applied to the body tag of the document,
|
||||
* the sidebar changes it's style to attempt to mimic a menu on a phone app,
|
||||
* where the content is overlaying the content, rather than push it.
|
||||
*/
|
||||
@media only screen and (max-width: 560px) {
|
||||
body.hamburg #page-wrapper {
|
||||
padding-left: 0;
|
||||
}
|
||||
body.hamburg #page-wrapper:not(.open) #sidebar-wrapper {
|
||||
position: absolute;
|
||||
left: -100px;
|
||||
}
|
||||
body.hamburg #page-wrapper:not(.open) ul.sidebar .sidebar-title.separator {
|
||||
display: none;
|
||||
}
|
||||
body.hamburg #page-wrapper.open #sidebar-wrapper {
|
||||
position: fixed;
|
||||
}
|
||||
body.hamburg #page-wrapper.open #sidebar-wrapper ul.sidebar li.sidebar-main {
|
||||
margin-left: 0px;
|
||||
}
|
||||
body.hamburg #sidebar-wrapper ul.sidebar li.sidebar-main,
|
||||
body.hamburg .row.header .meta {
|
||||
margin-left: 70px;
|
||||
}
|
||||
body.hamburg #sidebar-wrapper ul.sidebar li.sidebar-main,
|
||||
body.hamburg #page-wrapper.open #sidebar-wrapper ul.sidebar li.sidebar-main {
|
||||
transition: margin-left 0.4s ease 0s;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
width: 40px;
|
||||
@@ -92,33 +49,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Fonts */
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('../fonts/montserrat-regular-webfont.eot');
|
||||
src: url('../fonts/montserrat-regular-webfont.eot?#iefix') format('embedded-opentype'), url('../fonts/montserrat-regular-webfont.woff') format('woff'),
|
||||
url('../fonts/montserrat-regular-webfont.ttf') format('truetype'), url('../fonts/montserrat-regular-webfont.svg#montserratregular') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
@media screen and (-webkit-min-device-pixel-ratio: 0) {
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
src: url('../fonts/montserrat-regular-webfont.svg') format('svg');
|
||||
}
|
||||
select {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
}
|
||||
/* Base */
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
body {
|
||||
background: var(--bg-body-color);
|
||||
font-family: 'Montserrat';
|
||||
color: var(--text-body-color) !important;
|
||||
}
|
||||
.row {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
@@ -129,22 +59,7 @@ body {
|
||||
.alerts-container .alert:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
#page-wrapper {
|
||||
padding-left: 70px;
|
||||
height: 100%;
|
||||
}
|
||||
#sidebar-wrapper {
|
||||
margin-left: -150px;
|
||||
left: -30px;
|
||||
width: 250px;
|
||||
position: fixed;
|
||||
height: 100%;
|
||||
z-index: 999;
|
||||
}
|
||||
#page-wrapper,
|
||||
#sidebar-wrapper {
|
||||
transition: all 0.4s ease 0s;
|
||||
}
|
||||
|
||||
.green {
|
||||
background: #23ae89 !important;
|
||||
}
|
||||
@@ -172,9 +87,8 @@ div.input-mask {
|
||||
-moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
|
||||
background: var(--bg-widget-color);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
border-color: var(--border-widget-color);
|
||||
border: 1px solid var(--border-widget);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.widget .widget-header .pagination,
|
||||
.widget .widget-footer .pagination {
|
||||
@@ -182,25 +96,23 @@ div.input-mask {
|
||||
}
|
||||
.widget .widget-header {
|
||||
color: var(--text-widget-header-color);
|
||||
background-color: var(--bg-widget-header-color);
|
||||
padding: 10px 15px;
|
||||
border-bottom: 1px solid var(--border-widget-color);
|
||||
padding: 20px 20px 10px 20px;
|
||||
line-height: 30px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.widget .widget-header i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.widget .widget-body {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.widget .widget-body table thead {
|
||||
background: var(--bg-widget-table-color);
|
||||
}
|
||||
.widget .widget-body table thead * {
|
||||
font-size: 14px !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
.widget .widget-body table tbody * {
|
||||
font-size: 13px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
.widget .widget-body .error {
|
||||
color: #ff0000;
|
||||
@@ -234,20 +146,7 @@ div.input-mask {
|
||||
border-top: 1px solid #e9e9e9;
|
||||
padding: 10px;
|
||||
}
|
||||
.widget .widget-icon {
|
||||
background: #30426a;
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
margin-right: 15px;
|
||||
}
|
||||
.widget .widget-icon i {
|
||||
line-height: 66px;
|
||||
color: #ffffff;
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.widget .widget-footer {
|
||||
border-top: 1px solid #e9e9e9;
|
||||
padding: 10px;
|
||||
|
||||
+192
-152
@@ -1,13 +1,12 @@
|
||||
/* Color Variable */
|
||||
html {
|
||||
--black-color: #000;
|
||||
--white-color: #fff;
|
||||
:root {
|
||||
--black-color: var(--ui-black);
|
||||
--white-color: var(--ui-white);
|
||||
|
||||
--grey-1: #212121;
|
||||
--grey-2: #181818;
|
||||
--grey-3: #383838;
|
||||
--grey-4: #585858;
|
||||
--grey-5: #323c48;
|
||||
--grey-6: #333333;
|
||||
--grey-7: #767676;
|
||||
--grey-8: #aaa;
|
||||
@@ -35,7 +34,6 @@ html {
|
||||
--grey-30: #444;
|
||||
--grey-31: #868686;
|
||||
--grey-32: #65798e;
|
||||
--grey-34: #314252;
|
||||
--grey-35: #546477;
|
||||
--grey-36: #55637d;
|
||||
--grey-37: #2d3e63;
|
||||
@@ -53,15 +51,12 @@ html {
|
||||
--grey-49: rgba(0, 0, 0, 0.54);
|
||||
--grey-50: rgba(161, 170, 166, 0.5);
|
||||
--grey-51: rgba(0, 0, 0, 0.15);
|
||||
--grey-52: rgba(255, 255, 255, 0.3);
|
||||
--grey-53: rgba(255, 255, 255, 0.6);
|
||||
--grey-54: rgb(54, 54, 54);
|
||||
--grey-55: rgba(255, 255, 255, 0.8);
|
||||
--grey-56: #b2bfdc;
|
||||
--grey-57: #999;
|
||||
--grey-58: #ebf4f8;
|
||||
--grey-59: #e6e6e6;
|
||||
--grey-60: #cacaca;
|
||||
--grey-61: rgb(231, 231, 231);
|
||||
|
||||
--blue-1: #219;
|
||||
@@ -76,7 +71,6 @@ html {
|
||||
--blue-10: #61b6ff;
|
||||
--blue-11: #3ea5ff;
|
||||
--blue-12: #41a6ff;
|
||||
--blue-13: #2361ae;
|
||||
--blue-14: #357ebd;
|
||||
|
||||
--red-1: #a94442;
|
||||
@@ -84,7 +78,6 @@ html {
|
||||
--red-3: #a11;
|
||||
--red-4: #d9534f;
|
||||
--red-5: #ff2727;
|
||||
--red-6: #ff00e0;
|
||||
--red-7: #f00;
|
||||
|
||||
--green-1: #164;
|
||||
@@ -93,35 +86,34 @@ html {
|
||||
|
||||
--orange-1: #e86925;
|
||||
|
||||
--BE-only: var(--orange-1);
|
||||
}
|
||||
--BE-only: var(--ui-warning-7);
|
||||
|
||||
/* Default Theme */
|
||||
:root {
|
||||
--bg-card-color: var(--grey-10);
|
||||
/* Default Theme */
|
||||
--bg-card-color: var(--white-color);
|
||||
--bg-main-color: var(--white-color);
|
||||
--bg-body-color: var(--grey-9);
|
||||
--bg-checkbox-border-color: var(--grey-49);
|
||||
--bg-sidebar-color: var(--grey-37);
|
||||
--bg-sidebar-header-color: var(--grey-37);
|
||||
--bg-sidebar-color: var(--ui-blue-10);
|
||||
--bg-sidebar-nav-color: var(--ui-blue-11);
|
||||
--bg-widget-color: var(--white-color);
|
||||
--bg-widget-header-color: var(--grey-10);
|
||||
--bg-widget-table-color: var(--grey-13);
|
||||
--bg-widget-table-color: var(--ui-gray-3);
|
||||
--bg-header-color: var(--white-color);
|
||||
--bg-hover-table-color: var(--grey-14);
|
||||
--bg-switch-box-color: var(--white-color);
|
||||
--bg-input-group-addon-color: var(--grey-11);
|
||||
--bg-btn-default-color: var(--white-color);
|
||||
--bg-blocklist-hover-color: var(--grey-12);
|
||||
--bg-boxselector-color: var(--white-color);
|
||||
--bg-switch-box-color: var(--ui-gray-5);
|
||||
--bg-input-group-addon-color: var(--ui-gray-3);
|
||||
--bg-btn-default-color: var(--ui-blue-10);
|
||||
--bg-blocklist-hover-color: var(--ui-blue-2);
|
||||
--bg-boxselector-color: var(--ui-gray-2);
|
||||
--bg-table-color: var(--white-color);
|
||||
--bg-md-checkbox-color: var(--grey-12);
|
||||
--bg-form-control-disabled-color: var(--grey-11);
|
||||
--bg-modal-content-color: var(--white-color);
|
||||
--bg-code-color: var(--grey-15);
|
||||
--bg-navtabs-color: var(--white-color);
|
||||
--bg-nav-container-color: var(--ui-gray-2);
|
||||
--bg-navtabs-hover-color: var(--grey-16);
|
||||
--bg-nav-tab-active-color: var(--ui-gray-4);
|
||||
--bg-table-selected-color: var(--grey-14);
|
||||
--bg-codemirror-color: var(--white-color);
|
||||
--bg-codemirror-gutters-color: var(--grey-17);
|
||||
--bg-dropdown-menu-color: var(--white-color);
|
||||
--bg-log-viewer-color: var(--white-color);
|
||||
@@ -129,20 +121,20 @@ html {
|
||||
--bg-pre-color: var(--grey-14);
|
||||
--bg-blocklist-item-selected-color: var(--grey-12);
|
||||
--bg-progress-color: var(--grey-14);
|
||||
--bg-pagination-color: var(--white-color);
|
||||
--bg-pagination-color: var(--ui-blue-3);
|
||||
--border-pagination-color: var(--ui-white);
|
||||
--bg-pagination-span-color: var(--white-color);
|
||||
--bg-pagination-hover-color: var(--grey-11);
|
||||
--bg-pagination-hover-color: var(--ui-blue-3);
|
||||
--bg-ui-select-hover-color: var(--grey-14);
|
||||
--bg-motd-body-color: var(--grey-20);
|
||||
--bg-item-highlighted-color: var(--grey-21);
|
||||
--bg-item-highlighted-null-color: var(--grey-14);
|
||||
--bg-row-header-color: var(--white-color);
|
||||
--bg-image-multiselect-button: linear-gradient(var(--white-color), var(--grey-17));
|
||||
--bg-multiselect-checkbox-color: var(--white-color);
|
||||
--bg-sidebar-wrapper-color: var(--blue-5);
|
||||
--bg-panel-body-color: var(--white-color);
|
||||
--bg-codemirror-color: var(--white-color);
|
||||
--bg-codemirror-selected-color: var(--grey-22);
|
||||
--bg-tooltip-color: var(--ui-gray-11);
|
||||
--bg-input-sm-color: var(--white-color);
|
||||
--bg-app-datatable-thead: var(--grey-23);
|
||||
--bg-app-datatable-tbody: var(--grey-24);
|
||||
--bg-multiselect-color: var(--white-color);
|
||||
--bg-daterangepicker-color: var(--white-color);
|
||||
--bg-calendar-color: var(--white-color);
|
||||
@@ -151,34 +143,41 @@ html {
|
||||
--bg-daterangepicker-hover: var(--grey-16);
|
||||
--bg-daterangepicker-in-range: var(--grey-58);
|
||||
--bg-daterangepicker-active: var(--blue-14);
|
||||
--bg-tooltip-color: var(--white-color);
|
||||
--bg-input-autofill-color: var(--white-color);
|
||||
--bg-btn-default-hover-color: var(--grey-59);
|
||||
--bg-btn-default-hover-color: var(--ui-blue-9);
|
||||
--bg-btn-focus: var(--grey-59);
|
||||
--bg-boxselector-disabled-color: var(--white-color);
|
||||
--bg-small-select-color: var(--white-color);
|
||||
--bg-app-datatable-thead: var(--grey-23);
|
||||
--bg-app-datatable-tbody: var(--grey-24);
|
||||
--bg-stepper-item-active: var(--white-color);
|
||||
--bg-stepper-item-counter: var(--grey-61);
|
||||
--bg-sortbutton-color: var(--white-color);
|
||||
--bg-dashboard-item: var(--ui-blue-3);
|
||||
--bg-searchbar: var(--ui-gray-2);
|
||||
--bg-inputbox: var(--ui-gray-2);
|
||||
--bg-dropdown-hover: var(--ui-gray-3);
|
||||
--bg-webeditor-color: var(--ui-gray-3);
|
||||
--bg-button-group-color: var(--ui-white);
|
||||
--bg-pagination-disabled-color: var(--ui-white);
|
||||
--bg-code-script-color: var(--ui-white);
|
||||
--bg-stepper-color: var(--ui-white);
|
||||
--bg-stepper-active-color: var(--ui-blue-1);
|
||||
--bg-code-color: var(--ui-white);
|
||||
|
||||
--text-main-color: var(--grey-7);
|
||||
--text-body-color: var(--grey-6);
|
||||
--text-sidebar-title-color: var(--blue-3);
|
||||
--text-widget-header-color: var(--grey-7);
|
||||
--text-widget-header-color: var(--ui-gray-11);
|
||||
--text-form-control-color: var(--grey-25);
|
||||
--text-muted-color: var(--grey-26);
|
||||
--text-link-color: var(--blue-2);
|
||||
--text-link-hover-color: var(--blue-4);
|
||||
--text-input-group-addon-color: var(--grey-25);
|
||||
--text-btn-default-color: var(--grey-6);
|
||||
--text-blocklist-hover-color: var(--grey-37);
|
||||
--text-dashboard-item-color: var(--grey-32);
|
||||
--text-danger-color: var(--red-1);
|
||||
--text-code-color: var(--red-2);
|
||||
--text-navtabs-color: var(--grey-25);
|
||||
--text-form-section-title-color: var(--grey-26);
|
||||
--text-code-color: var(--ui-gray-9);
|
||||
--text-navtabs-color: var(--grey-7);
|
||||
--text-navtabs-hover-color: var(--grey-6);
|
||||
--text-nav-tab-active-color: var(--grey-25);
|
||||
--text-cm-default-color: var(--blue-1);
|
||||
--text-cm-meta-color: var(--black-color);
|
||||
--text-cm-string-color: var(--red-3);
|
||||
@@ -193,27 +192,29 @@ html {
|
||||
--text-blocklist-item-selected-color: var(--grey-37);
|
||||
--text-progress-bar-color: var(--grey-27);
|
||||
--text-pagination-color: var(--grey-26);
|
||||
--text-pagination-span-color: var(--blue-2);
|
||||
--text-pagination-span-hover-color: var(--blue-4);
|
||||
--text-pagination-span-color: var(--grey-3);
|
||||
--text-pagination-span-hover-color: var(--grey-3);
|
||||
--text-ui-select-color: var(--grey-6);
|
||||
--text-ui-select-hover-color: var(--grey-28);
|
||||
--text-summary-color: var(--black-color);
|
||||
--text-multiselect-button-color: var(--grey-29);
|
||||
--text-multiselect-item-color: var(--grey-30);
|
||||
--text-sidebar-list-color: var(--grey-56);
|
||||
--text-tooltip-color: var(--white-color);
|
||||
--text-rzslider-color: var(--grey-36);
|
||||
--text-rzslider-limit-color: var(--grey-36);
|
||||
--text-daterangepicker-end-date: var(--grey-57);
|
||||
--text-daterangepicker-in-range: var(--black-color);
|
||||
--text-daterangepicker-active: var(--white-color);
|
||||
--text-tooltip-color: var(--grey-6);
|
||||
--text-input-autofill-color: var(--black-color);
|
||||
--text-button-hover-color: var(--grey-6);
|
||||
--text-small-select-color: var(--grey-25);
|
||||
--text-bootbox: var(--ui-gray-7);
|
||||
--text-button-group-color: var(--ui-gray-9);
|
||||
--text-button-dangerlight-color: var(--ui-error-5);
|
||||
--text-stepper-active-color: var(--ui-blue-8);
|
||||
--text-boxselector-header: var(--ui-black);
|
||||
|
||||
--border-color: var(--grey-42);
|
||||
--border-widget-color: var(--grey-43);
|
||||
--border-sidebar-color: var(--white-color);
|
||||
--border-sidebar-color: var(--ui-blue-9);
|
||||
--border-form-control-color: var(--grey-44);
|
||||
--border-table-color: var(--grey-19);
|
||||
--border-table-top-color: var(--grey-19);
|
||||
@@ -224,35 +225,33 @@ html {
|
||||
--border-boxselector-color: var(--grey-6);
|
||||
--border-md-checkbox-color: var(--grey-19);
|
||||
--border-modal-header-color: var(--grey-45);
|
||||
--border-navtabs-color: var(--grey-19);
|
||||
--border-form-section-title-color: var(--grey-26);
|
||||
--border-navtabs-color: var(--ui-white);
|
||||
--border-codemirror-cursor-color: var(--black-color);
|
||||
--border-codemirror-gutters-color: var(--grey-19);
|
||||
--border-pre-color: var(--grey-43);
|
||||
--border-blocklist-item-selected-color: var(--grey-46);
|
||||
--border-pagination-color: var(--grey-19);
|
||||
--border-pagination-span-color: var(--grey-19);
|
||||
--border-pagination-hover-color: var(--grey-19);
|
||||
--border-multiselect-button-color: var(--grey-48);
|
||||
--border-searchbar-color: var(--grey-10);
|
||||
--border-pagination-span-color: var(--ui-white);
|
||||
--border-pagination-hover-color: var(--ui-white);
|
||||
--border-panel-color: var(--white-color);
|
||||
--border-input-sm-color: var(--grey-47);
|
||||
--border-daterangepicker-color: var(--grey-19);
|
||||
--border-calendar-table: var(--white-color);
|
||||
--border-daterangepicker: var(--grey-19);
|
||||
--border-pre-next-month: var(--black-color);
|
||||
--border-daterangepicker-after: var(--white-color);
|
||||
--border-tooltip-color: var(--grey-47);
|
||||
--border-modal: 0px;
|
||||
--border-sortbutton: var(--grey-8);
|
||||
--border-bootbox: var(--ui-gray-5);
|
||||
--border-blocklist: var(--ui-gray-5);
|
||||
--border-widget: var(--ui-gray-5);
|
||||
--border-nav-container-color: var(--ui-gray-5);
|
||||
--border-stepper-color: var(--ui-gray-4);
|
||||
|
||||
--hover-sidebar-color: var(--grey-37);
|
||||
--shadow-box-color: 0 3px 10px -2px var(--grey-50);
|
||||
--shadow-boxselector-color: 0 3px 10px -2px var(--grey-50);
|
||||
--blue-color: var(--blue-13);
|
||||
--button-close-color: var(--black-color);
|
||||
--button-opacity: 0.2;
|
||||
--button-opacity-hover: 0.5;
|
||||
--bg-boxselector-wrapper-color: var(--grey-6);
|
||||
|
||||
--bg-image-multiselect: linear-gradient(var(--blue-2), var(--blue-2));
|
||||
--bg-image-multiselect-button: linear-gradient(var(--white-color), var(--grey-17));
|
||||
@@ -265,95 +264,112 @@ html {
|
||||
--text-multiselect-item: var(--grey-30);
|
||||
--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);
|
||||
|
||||
--user-menu-icon-color: var(--ui-gray-4);
|
||||
--sort-icon-muted: var(--ui-gray-5);
|
||||
--sort-icon-hover: var(--ui-gray-6);
|
||||
--sort-icon: var(--ui-gray-9);
|
||||
--border-checkbox: var(--ui-gray-5);
|
||||
--bg-checkbox: var(--white-color);
|
||||
--border-searchbar: var(--grey-44);
|
||||
--bg-button-group: var(--white-color);
|
||||
--border-button-group: var(--ui-gray-5);
|
||||
--text-button-group: var(--ui-gray-9);
|
||||
}
|
||||
|
||||
:root[theme='dark'] {
|
||||
--bg-card-color: var(--grey-1);
|
||||
--bg-main-color: var(--grey-2);
|
||||
/* Dark Theme */
|
||||
[theme='dark'] {
|
||||
--bg-body-color: var(--grey-2);
|
||||
--bg-btn-default-color: var(--grey-3);
|
||||
--bg-blocklist-hover-color: var(--ui-gray-iron-10);
|
||||
--bg-boxselector-color: var(--ui-gray-iron-10);
|
||||
--bg-blocklist-item-selected-color: var(--grey-3);
|
||||
--bg-card-color: var(--grey-1);
|
||||
--bg-checkbox-border-color: var(--grey-8);
|
||||
--bg-sidebar-color: var(--grey-3);
|
||||
--bg-code-color: var(--grey-2);
|
||||
--bg-codemirror-color: var(--grey-2);
|
||||
--bg-codemirror-gutters-color: var(--grey-3);
|
||||
--bg-codemirror-selected-color: var(--grey-3);
|
||||
--bg-dropdown-menu-color: var(--ui-gray-7);
|
||||
--bg-main-color: var(--grey-2);
|
||||
--bg-sidebar-color: var(--grey-1);
|
||||
--bg-sidebar-nav-color: var(--grey-2);
|
||||
--bg-widget-color: var(--grey-1);
|
||||
--bg-widget-header-color: var(--grey-1);
|
||||
--bg-widget-table-color: var(--grey-1);
|
||||
--bg-widget-header-color: var(--grey-3);
|
||||
--bg-widget-table-color: var(--grey-3);
|
||||
--bg-header-color: var(--grey-2);
|
||||
--bg-hover-table-color: var(--grey-3);
|
||||
--bg-switch-box-color: var(--grey-53);
|
||||
--bg-input-group-addon-color: var(--grey-3);
|
||||
--bg-btn-default-color: var(--grey-3);
|
||||
--bg-blocklist-hover-color: var(--grey-3);
|
||||
--bg-boxselector-color: var(--grey-54);
|
||||
--bg-table-color: var(--grey-1);
|
||||
--bg-md-checkbox-color: var(--grey-31);
|
||||
--bg-form-control-disabled-color: var(--grey-3);
|
||||
--bg-modal-content-color: var(--grey-1);
|
||||
--bg-code-color: var(--red-4);
|
||||
--bg-navtabs-color: var(--grey-3);
|
||||
--bg-nav-container-color: var(--ui-gray-iron-10);
|
||||
--bg-navtabs-hover-color: var(--grey-3);
|
||||
--bg-nav-tab-active-color: var(--grey-2);
|
||||
--bg-table-selected-color: var(--grey-3);
|
||||
--bg-codemirror-color: var(--grey-2);
|
||||
--bg-codemirror-gutters-color: var(--grey-2);
|
||||
--bg-dropdown-menu-color: var(--grey-1);
|
||||
--bg-log-viewer-color: var(--grey-2);
|
||||
--bg-log-line-selected-color: var(--grey-3);
|
||||
--bg-pre-color: var(--grey-2);
|
||||
--bg-blocklist-item-selected-color: var(--grey-3);
|
||||
--bg-progress-color: var(--grey-3);
|
||||
--bg-pagination-color: var(--grey-3);
|
||||
--bg-pagination-span-color: var(--grey-3);
|
||||
--bg-pagination-hover-color: var(--grey-4);
|
||||
--bg-pagination-span-color: var(--grey-1);
|
||||
--bg-pagination-hover-color: var(--grey-3);
|
||||
--bg-ui-select-hover-color: var(--grey-3);
|
||||
--bg-motd-body-color: var(--grey-1);
|
||||
--bg-item-highlighted-color: var(--grey-2);
|
||||
--bg-item-highlighted-null-color: var(--grey-2);
|
||||
--bg-row-header-color: var(--grey-2);
|
||||
--bg-multiselect-button-color: var(--grey-3);
|
||||
--bg-image-multiselect-button: none !important;
|
||||
--bg-multiselect-checkbox-color: var(--grey-3);
|
||||
--bg-sidebar-wrapper-color: var(--grey-1);
|
||||
--bg-panel-body-color: var(--grey-1);
|
||||
--bg-boxselector-wrapper-disabled-color: var(--grey-39);
|
||||
--bg-codemirror-selected-color: var(--grey-3);
|
||||
--bg-sidebar-header-color: var(--grey-1);
|
||||
--bg-input-group-addon-color: var(--grey-3);
|
||||
--bg-tooltip-color: var(--grey-3);
|
||||
--bg-input-sm-color: var(--grey-1);
|
||||
--bg-service-datatable-thead: var(--grey-1);
|
||||
--bg-inner-datatable-thead: var(--grey-1);
|
||||
--bg-app-datatable-thead: var(--grey-1);
|
||||
--bg-service-datatable-tbody: var(--grey-1);
|
||||
--bg-app-datatable-tbody: var(--grey-1);
|
||||
--bg-multiselect-color: var(--grey-1);
|
||||
--bg-daterangepicker-color: var(--grey-3);
|
||||
--bg-calendar-color: var(--grey-3);
|
||||
--bg-calendar-table-color: var(--grey-3);
|
||||
--bg-daterangepicker-end-date: var(--grey-4);
|
||||
--bg-daterangepicker-hover: var(--grey-4);
|
||||
--bg-daterangepicker-in-range: var(--grey-2);
|
||||
--bg-daterangepicker-in-range: var(--ui-gray-warm-11);
|
||||
--bg-daterangepicker-active: var(--blue-14);
|
||||
--bg-tooltip-color: var(--grey-3);
|
||||
--bg-input-autofill-color: var(--grey-2);
|
||||
--bg-btn-default-hover-color: var(--grey-3);
|
||||
--bg-btn-default-hover-color: var(--grey-4);
|
||||
--bg-btn-focus: var(--grey-3);
|
||||
--bg-boxselector-disabled-color: var(--grey-54);
|
||||
--bg-small-select-color: var(--grey-2);
|
||||
--bg-app-datatable-thead: var(--grey-1);
|
||||
--bg-app-datatable-tbody: var(--grey-1);
|
||||
--bg-stepper-item-active: var(--grey-1);
|
||||
--bg-stepper-item-counter: var(--grey-7);
|
||||
--bg-sortbutton-color: var(--grey-1);
|
||||
--bg-dashboard-item: var(--grey-3);
|
||||
--bg-searchbar: var(--ui-grey-warm-11);
|
||||
--bg-inputbox: var(--grey-2);
|
||||
--bg-dropdown-hover: var(--grey-3);
|
||||
--bg-webeditor-color: var(--ui-gray-iron-10);
|
||||
--bg-button-group-color: var(--ui-black);
|
||||
--bg-pagination-disabled-color: var(--grey-1);
|
||||
--bg-code-script-color: var(--ui-gray-warm-11);
|
||||
--bg-stepper-color: var(--ui-gray-iron-10);
|
||||
--bg-stepper-active-color: var(--ui-blue-8);
|
||||
|
||||
--text-main-color: var(--white-color);
|
||||
--text-body-color: var(--white-color);
|
||||
--text-sidebar-title-color: var(--grey-8);
|
||||
--text-widget-header-color: var(--white-color);
|
||||
--text-form-control-color: var(--white-color);
|
||||
--text-muted-color: var(--grey-8);
|
||||
--text-link-color: var(--blue-9);
|
||||
--text-link-hover-color: var(--blue-2);
|
||||
--text-input-group-addon-color: var(--grey-8);
|
||||
--text-btn-default-color: var(--grey-8);
|
||||
--text-blocklist-hover-color: var(--white-color);
|
||||
--text-dashboard-item-color: var(--blue-2);
|
||||
--text-danger-color: var(--red-4);
|
||||
--text-code-color: var(--white-color);
|
||||
--text-navtabs-color: var(--white-color);
|
||||
--text-form-section-title-color: var(--grey-8);
|
||||
--text-navtabs-color: var(--grey-8);
|
||||
--text-navtabs-hover-color: var(--grey-9);
|
||||
--text-nav-tab-active-color: var(--white-color);
|
||||
--text-cm-default-color: var(--blue-10);
|
||||
--text-cm-meta-color: var(--white-color);
|
||||
--text-cm-string-color: var(--red-5);
|
||||
@@ -368,27 +384,30 @@ html {
|
||||
--text-blocklist-item-selected-color: var(--white-color);
|
||||
--text-progress-bar-color: var(--white-color);
|
||||
--text-pagination-color: var(--white-color);
|
||||
--text-pagination-span-color: var(--blue-2);
|
||||
--text-pagination-span-hover-color: var(--white-color);
|
||||
--text-pagination-span-color: var(--ui-white);
|
||||
--text-pagination-span-hover-color: var(--ui-white);
|
||||
--text-ui-select-color: var(--white-color);
|
||||
--text-ui-select-hover-color: var(--white-color);
|
||||
--text-summary-color: var(--white-color);
|
||||
--text-multiselect-button-color: var(--white-color);
|
||||
--text-multiselect-item-color: var(--white-color);
|
||||
--text-sidebar-list-color: var(--white-color);
|
||||
--text-boxselector-wrapper-color: var(--white-color);
|
||||
--text-tooltip-color: var(--white-color);
|
||||
--text-rzslider-color: var(--white-color);
|
||||
--text-rzslider-limit-color: var(--white-color);
|
||||
--text-daterangepicker-end-date: var(--grey-7);
|
||||
--text-daterangepicker-in-range: var(--white-color);
|
||||
--text-daterangepicker-active: var(--white-color);
|
||||
--text-tooltip-color: var(--white-color);
|
||||
--text-btn-default-color: var(--white-color);
|
||||
--text-input-autofill-color: var(--grey-8);
|
||||
--text-button-hover-color: var(--white-color);
|
||||
--text-small-select-color: var(--grey-7);
|
||||
--text-bootbox: var(--white-color);
|
||||
--text-button-group-color: var(--ui-white);
|
||||
--text-button-dangerlight-color: var(--ui-error-7);
|
||||
--text-stepper-active-color: var(--ui-white);
|
||||
--text-boxselector-header: var(--ui-white);
|
||||
|
||||
--border-color: var(--grey-3);
|
||||
--border-widget-color: var(--grey-1);
|
||||
--border-sidebar-color: var(--blue-9);
|
||||
--border-sidebar-color: var(--ui-gray-8);
|
||||
--border-form-control-color: var(--grey-54);
|
||||
--border-table-color: var(--grey-3);
|
||||
--border-table-top-color: var(--grey-3);
|
||||
@@ -400,28 +419,27 @@ html {
|
||||
--border-md-checkbox-color: var(--grey-41);
|
||||
--border-modal-header-color: var(--grey-1);
|
||||
--border-navtabs-color: var(--grey-38);
|
||||
--border-form-section-title-color: var(--grey-8);
|
||||
--border-codemirror-cursor-color: var(--white-color);
|
||||
--border-codemirror-gutters-color: var(--grey-26);
|
||||
--border-pre-color: var(--grey-3);
|
||||
--border-blocklist-item-selected-color: var(--grey-38);
|
||||
--border-pagination-color: var(--grey-3);
|
||||
--border-pagination-span-color: var(--grey-3);
|
||||
--border-pagination-span-color: var(--grey-1);
|
||||
--border-pagination-hover-color: var(--grey-3);
|
||||
--border-pagination-hover-color: var(--grey-3);
|
||||
--border-multiselect-button-color: var(--grey-3);
|
||||
--border-searchbar-color: var(--grey-1);
|
||||
--border-panel-color: var(--grey-2);
|
||||
--border-input-sm-color: var(--grey-3);
|
||||
--border-daterangepicker-color: var(--grey-3);
|
||||
--border-calendar-table: var(--grey-3);
|
||||
--border-daterangepicker: var(--grey-4);
|
||||
--border-pre-next-month: var(--white-color);
|
||||
--border-daterangepicker-after: var(--grey-3);
|
||||
--border-tooltip-color: var(--grey-3);
|
||||
--border-modal: 0px;
|
||||
--border-sortbutton: var(--grey-3);
|
||||
--border-bootbox: var(--ui-gray-9);
|
||||
--border-blocklist: var(--ui-gray-9);
|
||||
--border-widget: var(--grey-3);
|
||||
--border-pagination-color: var(--grey-1);
|
||||
--border-nav-container-color: var(--ui-gray-neutral-8);
|
||||
--border-stepper-color: var(--ui-gray-warm-9);
|
||||
|
||||
--hover-sidebar-color: var(--grey-3);
|
||||
--blue-color: var(--blue-2);
|
||||
--button-close-color: var(--white-color);
|
||||
--button-opacity: 0.6;
|
||||
@@ -439,17 +457,27 @@ html {
|
||||
--text-multiselect-item: var(--white-color);
|
||||
--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);
|
||||
|
||||
--user-menu-icon-color: var(--grey-3);
|
||||
--sort-icon-muted: var(--ui-gray-7);
|
||||
--sort-icon-hover: var(--ui-gray-6);
|
||||
--sort-icon: var(--ui-gray-3);
|
||||
--border-checkbox: var(--ui-gray-5);
|
||||
--bg-checkbox: var(--white-color);
|
||||
--border-searchbar: var(--grey-54);
|
||||
--bg-button-group: var(--white-color);
|
||||
--border-button-group: var(--ui-gray-5);
|
||||
--text-button-group: var(--ui-gray-9);
|
||||
}
|
||||
|
||||
:root[theme='highcontrast'] {
|
||||
/* High Contrast Theme */
|
||||
[theme='highcontrast'] {
|
||||
--bg-card-color: var(--black-color);
|
||||
--bg-main-color: var(--black-color);
|
||||
--bg-body-color: var(--black-color);
|
||||
--bg-checkbox-border-color: var(--grey-8);
|
||||
--bg-sidebar-color: var(--black-color);
|
||||
--bg-sidebar-nav-color: var(--black-color);
|
||||
--bg-widget-color: var(--black-color);
|
||||
--bg-widget-header-color: var(--black-color);
|
||||
--bg-widget-table-color: var(--black-color);
|
||||
@@ -457,31 +485,29 @@ html {
|
||||
--bg-hover-table-color: var(--grey-3);
|
||||
--bg-switch-box-color: var(--grey-53);
|
||||
--bg-panel-body-color: var(--black-color);
|
||||
--bg-boxselector-wrapper-disabled-color: var(--grey-39);
|
||||
--bg-dropdown-menu-color: var(--black-color);
|
||||
--bg-codemirror-selected-color: var(--grey-3);
|
||||
--bg-row-header-color: var(--black-color);
|
||||
--bg-sidebar-wrapper-color: var(--black-color);
|
||||
--bg-motd-body-color: var(--black-color);
|
||||
--bg-blocklist-hover-color: var(--black-color);
|
||||
--bg-blocklist-item-selected-color: var(--black-color);
|
||||
--bg-input-group-addon-color: var(--grey-1);
|
||||
--bg-input-group-addon-color: var(--grey-3);
|
||||
--bg-table-color: var(--black-color);
|
||||
--bg-codemirror-gutters-color: var(--black-color);
|
||||
--bg-codemirror-gutters-color: var(--ui-gray-warm-11);
|
||||
--bg-codemirror-color: var(--black-color);
|
||||
--bg-codemirror-selected-color: var(--grey-3);
|
||||
--bg-log-viewer-color: var(--black-color);
|
||||
--bg-log-line-selected-color: var(--grey-3);
|
||||
--bg-sidebar-header-color: var(--black-color);
|
||||
--bg-modal-content-color: var(--black-color);
|
||||
--bg-form-control-disabled-color: var(--grey-1);
|
||||
--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-app-datatable-thead: var(--black-color);
|
||||
--bg-service-datatable-tbody: var(--black-color);
|
||||
--bg-app-datatable-tbody: var(--black-color);
|
||||
--bg-pagination-color: var(--grey-3);
|
||||
--bg-pagination-span-color: var(--grey-3);
|
||||
--bg-pagination-span-color: var(--ui-black);
|
||||
--bg-multiselect-color: var(--grey-1);
|
||||
--bg-daterangepicker-color: var(--black-color);
|
||||
--bg-calendar-color: var(--black-color);
|
||||
@@ -493,29 +519,32 @@ html {
|
||||
--bg-tooltip-color: var(--black-color);
|
||||
--bg-table-selected-color: var(--grey-3);
|
||||
--bg-pre-color: var(--grey-2);
|
||||
--bg-nav-container-color: var(--ui-black);
|
||||
--bg-navtabs-hover-color: var(--grey-3);
|
||||
--bg-nav-tab-active-color: var(--ui-black);
|
||||
--bg-btn-default-color: var(--black-color);
|
||||
--bg-code-color: var(--red-4);
|
||||
--bg-navtabs-color: var(--black-color);
|
||||
--bg-input-autofill-color: var(--black-color);
|
||||
--bg-code-color: var(--grey-2);
|
||||
--bg-navtabs-color: var(--grey-2);
|
||||
--bg-navtabs-hover-color: var(--grey-3);
|
||||
--bg-btn-default-hover-color: var(--grey-3);
|
||||
--bg-btn-default-color: var(--black-color);
|
||||
--bg-code-color: var(--ui-black);
|
||||
--bg-btn-default-hover-color: var(--grey-4);
|
||||
--bg-btn-focus: var(--black-color);
|
||||
--bg-boxselector-color: var(--black-color);
|
||||
--bg-boxselector-disabled-color: var(--black-color);
|
||||
--bg-small-select-color: var(--black-color);
|
||||
--bg-app-datatable-thead: var(--black-color);
|
||||
--bg-app-datatable-tbody: var(--black-color);
|
||||
--bg-stepper-item-active: var(--black-color);
|
||||
--bg-stepper-item-counter: var(--grey-3);
|
||||
--bg-sortbutton-color: var(--grey-1);
|
||||
--bg-inputbox: var(--black-color);
|
||||
--bg-searchbar: var(--black-color);
|
||||
--bg-dropdown-hover: var(--black-color);
|
||||
--bg-webeditor-color: var(--ui-gray-warm-9);
|
||||
--bg-pagination-disabled-color: var(--ui-black);
|
||||
--bg-pagination-hover-color: var(--ui-black);
|
||||
--bg-code-script-color: var(--ui-black);
|
||||
--bg-stepper-active-color: var(--ui-blue-8);
|
||||
--bg-stepper-color: var(--ui-black);
|
||||
|
||||
--text-main-color: var(--white-color);
|
||||
--text-body-color: var(--white-color);
|
||||
--text-sidebar-title-color: var(--grey-8);
|
||||
--text-widget-header-color: var(--white-color);
|
||||
--text-link-color: var(--blue-9);
|
||||
--text-link-hover-color: var(--blue-9);
|
||||
@@ -525,7 +554,6 @@ html {
|
||||
--text-blocklist-hover-color: var(--blue-11);
|
||||
--text-boxselector-wrapper-color: var(--white-color);
|
||||
--text-dashboard-item-color: var(--blue-12);
|
||||
--text-form-section-title-color: var(--white-color);
|
||||
--text-muted-color: var(--white-color);
|
||||
--text-tooltip-color: var(--white-color);
|
||||
--text-blocklist-item-selected-color: var(--blue-9);
|
||||
@@ -537,56 +565,57 @@ html {
|
||||
--text-rzslider-color: var(--white-color);
|
||||
--text-rzslider-limit-color: var(--white-color);
|
||||
--text-pagination-color: var(--white-color);
|
||||
--text-daterangepicker-end-date: var(--grey-7);
|
||||
--text-daterangepicker-end-date: var(--ui-white);
|
||||
--text-daterangepicker-in-range: var(--white-color);
|
||||
--text-daterangepicker-active: var(--white-color);
|
||||
--text-sidebar-list-color: var(--white-color);
|
||||
--text-ui-select-color: var(--white-color);
|
||||
--text-btn-default-color: var(--white-color);
|
||||
--text-json-tree-color: var(--white-color);
|
||||
--text-json-tree-leaf-color: var(--white-color);
|
||||
--text-json-tree-branch-preview-color: var(--white-color);
|
||||
--text-pre-color: var(--white-color);
|
||||
--text-navtabs-color: var(--white-color);
|
||||
--text-navtabs-hover-color: var(--white-color);
|
||||
--text-nav-tab-active-color: var(--white-color);
|
||||
--text-input-autofill-color: var(--white-color);
|
||||
--text-navtabs-color: var(--white-color);
|
||||
--text-button-hover-color: var(--white-color);
|
||||
--text-btn-default-color: var(--white-color);
|
||||
--text-small-select-color: var(--white-color);
|
||||
--text-multiselect-item-color: var(--white-color);
|
||||
--text-pagination-span-color: var(--blue-2);
|
||||
--text-pagination-span-color: var(--ui-white);
|
||||
--text-bootbox: var(--white-color);
|
||||
--text-pagination-span-hover-color: var(--ui-white);
|
||||
--text-stepper-active-color: var(--ui-white);
|
||||
--text-boxselector-header: var(--ui-white);
|
||||
|
||||
--border-color: var(--grey-55);
|
||||
--border-widget-color: var(--white-color);
|
||||
--border-sidebar-color: var(--blue-9);
|
||||
--border-sidebar-color: var(--white-color);
|
||||
--border-form-control-color: var(--grey-54);
|
||||
--border-table-color: var(--grey-55);
|
||||
--border-table-top-color: var(--grey-55);
|
||||
--border-datatable-top-color: var(--grey-55);
|
||||
--border-sidebar-high-contrast: 1px solid var(--blue-9);
|
||||
--border-code-high-contrast: 1px solid var(--white-color);
|
||||
--border-boxselector-wrapper: 3px solid var(--blue-2);
|
||||
--border-boxselector-wrapper-hover: 3px solid var(--blue-8);
|
||||
--border-panel-color: var(--white-color);
|
||||
--border-input-group-addon-color: var(--grey-54);
|
||||
--border-modal-header-color: var(--grey-3);
|
||||
--border-input-sm-color: var(--white-color);
|
||||
--border-pagination-color: var(--grey-3);
|
||||
--border-pagination-span-color: var(--grey-3);
|
||||
--border-pagination-color: var(--grey-1);
|
||||
--border-pagination-span-color: var(--grey-1);
|
||||
--border-daterangepicker-color: var(--white-color);
|
||||
--border-calendar-table: var(--black-color);
|
||||
--border-daterangepicker: var(--black-color);
|
||||
--border-pre-next-month: var(--white-color);
|
||||
--border-daterangepicker-after: var(--black-color);
|
||||
--border-tooltip-color: var(--white-color);
|
||||
--border-pre-color: var(--grey-3);
|
||||
--border-codemirror-cursor-color: var(--white-color);
|
||||
--border-modal: 1px solid var(--white-color);
|
||||
--border-blocklist-color: var(--white-color);
|
||||
--border-sortbutton: var(--black-color);
|
||||
--border-bootbox: var(--black-color);
|
||||
--border-blocklist: var(--white-color);
|
||||
--border-widget: var(--white-color);
|
||||
--border-nav-container-color: var(--ui-white);
|
||||
--border-stepper-color: var(--ui-gray-warm-9);
|
||||
|
||||
--hover-sidebar-color: var(--blue-9);
|
||||
--hover-sidebar-color: var(--black-color);
|
||||
--shadow-box-color: none;
|
||||
--shadow-boxselector-color: none;
|
||||
|
||||
@@ -605,4 +634,15 @@ html {
|
||||
--text-cm-meta-color: var(--white-color);
|
||||
--text-cm-string-color: var(--red-7);
|
||||
--text-progress-bar-color: var(--black-color);
|
||||
|
||||
--user-menu-icon-color: var(--white-color);
|
||||
--sort-icon-muted: var(--ui-gray-7);
|
||||
--sort-icon-hover: var(--ui-gray-6);
|
||||
--sort-icon: var(--ui-gray-3);
|
||||
--border-checkbox: var(--ui-gray-5);
|
||||
--bg-checkbox: var(--white-color);
|
||||
--border-searchbar: var(--ui-gray-5);
|
||||
--bg-button-group: var(--white-color);
|
||||
--border-button-group: var(--ui-gray-5);
|
||||
--text-button-group: var(--ui-gray-9);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Overide Vendor CSS */
|
||||
.form-control {
|
||||
background-color: var(--bg-main-color) !important;
|
||||
border: 1px solid var(--border-form-control-color);
|
||||
background-color: var(--bg-inputbox);
|
||||
color: var(--text-form-control-color);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
|
||||
.table > thead > tr > th {
|
||||
border-bottom: 2px solid var(--border-table-color);
|
||||
border-bottom: 1px solid var(--border-table-color);
|
||||
}
|
||||
|
||||
.table-hover > tbody > tr:hover {
|
||||
@@ -31,33 +31,18 @@
|
||||
border-top: 1px solid var(--border-table-top-color);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-link-color);
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus {
|
||||
color: var(--text-link-hover-color);
|
||||
}
|
||||
|
||||
.input-group-addon {
|
||||
color: var(--text-input-group-addon-color);
|
||||
background-color: var(--bg-input-group-addon-color);
|
||||
border: 1px solid var(--border-input-group-addon-color);
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
color: var(--text-btn-default-color);
|
||||
background-color: var(--bg-btn-default-color);
|
||||
border-color: var(--border-btn-default-color);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--text-danger-color);
|
||||
color: var(--ui-error-9);
|
||||
}
|
||||
|
||||
.table .table {
|
||||
background-color: var(--bg-table-color);
|
||||
background-color: initial;
|
||||
}
|
||||
|
||||
.table-bordered {
|
||||
@@ -119,8 +104,8 @@ code {
|
||||
.nav-tabs > li.active > a,
|
||||
.nav-tabs > li.active > a:hover,
|
||||
.nav-tabs > li.active > a:focus {
|
||||
color: var(--text-navtabs-color);
|
||||
background-color: var(--bg-navtabs-color);
|
||||
color: var(--text-nav-tab-active-color);
|
||||
background-color: var(--bg-nav-tab-active-color);
|
||||
border: 1px solid var(--border-navtabs-color);
|
||||
}
|
||||
|
||||
@@ -132,8 +117,13 @@ code {
|
||||
border-color: var(--border-navtabs-color);
|
||||
}
|
||||
|
||||
.nav > li > a {
|
||||
color: var(--text-navtabs-color);
|
||||
}
|
||||
|
||||
.nav > li > a:hover,
|
||||
.nav > li > a:focus {
|
||||
color: var(--text-navtabs-hover-color);
|
||||
background-color: var(--bg-navtabs-hover-color);
|
||||
}
|
||||
|
||||
@@ -161,12 +151,22 @@ code {
|
||||
|
||||
.CodeMirror-gutters {
|
||||
background: var(--bg-codemirror-gutters-color);
|
||||
border-right: 1px solid var(--border-codemirror-gutters-color);
|
||||
border-right: 0px;
|
||||
}
|
||||
|
||||
.CodeMirror-linenumber {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.CodeMirror pre.CodeMirror-line,
|
||||
.CodeMirror pre.CodeMirror-line-like {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
background: var(--bg-codemirror-color);
|
||||
color: var(--text-codemirror-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.CodeMirror-selected {
|
||||
@@ -195,6 +195,7 @@ code {
|
||||
|
||||
.dropdown-menu {
|
||||
background: var(--bg-dropdown-menu-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dropdown-menu > li > a {
|
||||
@@ -203,6 +204,7 @@ code {
|
||||
|
||||
pre {
|
||||
border: 1px solid var(--border-pre-color);
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-pre-color);
|
||||
color: var(--text-pre-color);
|
||||
}
|
||||
@@ -222,6 +224,27 @@ json-tree .branch-preview {
|
||||
background-color: var(--bg-progress-color);
|
||||
}
|
||||
|
||||
.ui-select-search,
|
||||
.ui-select-toggle {
|
||||
height: 30px;
|
||||
min-width: 260px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.ui-select-toggle {
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.ui-select-match-text {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ui-select-match-text > a {
|
||||
verical-align: middle;
|
||||
}
|
||||
|
||||
.ui-select-bootstrap .ui-select-choices-row > span {
|
||||
color: var(--text-ui-select-color);
|
||||
}
|
||||
@@ -243,6 +266,10 @@ json-tree .branch-preview {
|
||||
.panel {
|
||||
border: 1px solid var(--border-panel-color);
|
||||
background-color: var(--bg-panel-body-color);
|
||||
border-radius: 8px;
|
||||
-webkit-box-shadow: 0 4px 4px rgba(0, 0, 0, 0.05);
|
||||
-moz-box-shadow: 0 4px 4px rgba(0, 0, 0, 0.05);
|
||||
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.theme-information .col-sm-12 {
|
||||
@@ -272,8 +299,15 @@ json-tree .branch-preview {
|
||||
.rzslider .rz-bubble.rz-limit {
|
||||
color: var(--text-rzslider-limit-color);
|
||||
}
|
||||
|
||||
.rz-bubble.rz-limit.rz-ceil {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: auto !important;
|
||||
top: -26px;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
textarea {
|
||||
background: var(--text-input-textarea);
|
||||
@@ -353,31 +387,59 @@ input:-webkit-autofill {
|
||||
-webkit-text-fill-color: var(--text-input-autofill-color) !important;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
color: var(--text-button-hover-color);
|
||||
}
|
||||
|
||||
.btn-default:hover {
|
||||
background-color: var(--bg-btn-default-hover-color);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
color: var(--white-color) !important;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
color: var(--white-color);
|
||||
}
|
||||
|
||||
/* Overide Vendor CSS */
|
||||
|
||||
.btn.disabled,
|
||||
.btn[disabled],
|
||||
fieldset[disabled] .btn {
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
.btn-link:hover {
|
||||
color: var(--text-link-hover-color) !important;
|
||||
}
|
||||
|
||||
.multiSelect.inlineBlock button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-tabs > li.active > a {
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
.label-default {
|
||||
line-height: 11px;
|
||||
}
|
||||
|
||||
/* Code Script Style */
|
||||
.code-script {
|
||||
background-color: var(--bg-code-script-color);
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
border: 1px solid var(--border-nav-container-color);
|
||||
background-color: var(--bg-nav-container-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.nav-tabs > li {
|
||||
background-color: var(--bg-nav-tabs-active-color);
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
|
||||
/* Code Script Style */
|
||||
.code-script {
|
||||
background-color: var(--bg-code-script-color);
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
border: 1px solid var(--border-nav-container-color);
|
||||
background-color: var(--bg-nav-container-color);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.nav-tabs > li {
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 86 KiB |
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user