feat(docker-desktop-extension): Make Portainer compatible with Docker Desktop Extension EE-2747 (#6644)

* Initial extension build

* Add auto login

fix auto auth

add some message

Add extension version

Double attempt to login

Add auto login from jwt check

Add autologin on logout

revert sidebar

Catch error 401 to relogin

cleanup login

Add password generator

Hide User block and collapse sidebar by default

hide user box and toggle sidebar

remove defailt dd

Integrate extension to portainer

Move extension to build

remove files from ignore

Move extension folder

fix alpine

try to copy folder

try add

Change base image

move folder extension

ignore folder build

Fix

relative path

Move ext to root

fix image name

versioned index

Update extension on same image

Update mod

* fix kubeshell baseurl

* Fix kube shell

* move build and remove https

* Tidy mod

* Remove space

* Fix hash test

* Password manager

* change to building locally

* Restore version variable and add local install command

* fix local dev image + hide users & auth

* Password manageListen on locahost onlyr

* FIxes base path

* Hide only username

* Move default to constants

* Update app/portainer/components/PageHeader/HeaderContent.html

Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>

* fix 2 failing FE tests [EE-2938]

* remove password autogeneration from v1

* fix webhooks

* fix docker container console and attach

* fix default for portainer IP

* update meta, dockerfile and makefile for new ver

* fix basepath in kube and docker console

* revert makefile changes

* add icon back

* Add remote short cut command

* make local methods the default

* default to 0.0.0 for version for local development

* simplify make commands

* small build fixes

* resolve conflicts

* Update api/filesystem/write.go

Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>

* use a more secure default pass

Co-authored-by: itsconquest <william.conquest@portainer.io>
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
pull/6775/head
Stéphane Busso 3 years ago committed by GitHub
parent 7efdae5eee
commit 360701e256
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,3 +1,5 @@
* *
!dist !dist
!build !build
!metadata.json
!docker-extension/build

@ -51,7 +51,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(), SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(),
Rollback: kingpin.Flag("rollback", "Rollback the database store to the previous version").Bool(), Rollback: kingpin.Flag("rollback", "Rollback the database store to the previous version").Bool(),
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(), SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").String(),
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(), AdminPassword: kingpin.Flag("admin-password", "Set admin password with provided hash").String(),
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(), AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(), Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),

@ -9,11 +9,11 @@ type Service struct{}
// Hash hashes a string using the bcrypt algorithm // Hash hashes a string using the bcrypt algorithm
func (*Service) Hash(data string) (string, error) { func (*Service) Hash(data string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost) bytes, err := bcrypt.GenerateFromPassword([]byte(data), bcrypt.DefaultCost)
if err != nil { if err != nil {
return "", nil return "", err
} }
return string(hash), nil return string(bytes), err
} }
// CompareHashAndData compares a hash to clear data and returns an error if the comparison fails. // CompareHashAndData compares a hash to clear data and returns an error if the comparison fails.

@ -0,0 +1,53 @@
package crypto
import (
"testing"
)
func TestService_Hash(t *testing.T) {
var s = &Service{}
type args struct {
hash string
data string
}
tests := []struct {
name string
args args
expect bool
}{
{
name: "Empty",
args: args{
hash: "",
data: "",
},
expect: false,
},
{
name: "Matching",
args: args{
hash: "$2a$10$6BFGd94oYx8k0bFNO6f33uPUpcpAJyg8UVX.akLe9EthF/ZBTXqcy",
data: "Passw0rd!",
},
expect: true,
},
{
name: "Not matching",
args: args{
hash: "$2a$10$ltKrUZ7492xyutHOb0/XweevU4jyw7QO66rP32jTVOMb3EX3JxA/a",
data: "Passw0rd!",
},
expect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := s.CompareHashAndData(tt.args.hash, tt.args.data)
if (err != nil) == tt.expect {
t.Errorf("Service.CompareHashAndData() = %v", err)
}
})
}
}

@ -7,6 +7,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
) )
// WriteToFile creates a file in the filesystem storage
func WriteToFile(dst string, content []byte) error { func WriteToFile(dst string, content []byte) error {
if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil { if err := os.MkdirAll(filepath.Dir(dst), 0744); err != nil {
return errors.Wrapf(err, "failed to create filestructure for the path %q", dst) return errors.Wrapf(err, "failed to create filestructure for the path %q", dst)

@ -1344,7 +1344,7 @@ type (
const ( const (
// APIVersion is the version number of the Portainer API // APIVersion is the version number of the Portainer API
APIVersion = "2.11.0" APIVersion = "2.11.1"
// DBVersion is the version number of the Portainer database // DBVersion is the version number of the Portainer database
DBVersion = 35 DBVersion = 35
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax

@ -14,6 +14,7 @@ export function configApp($urlRouterProvider, $httpProvider, localStorageService
tokenGetter: /* @ngInject */ function tokenGetter(LocalStorage) { tokenGetter: /* @ngInject */ function tokenGetter(LocalStorage) {
return LocalStorage.getJWT(); return LocalStorage.getJWT();
}, },
whiteListedDomains: ['localhost'],
}); });
$httpProvider.interceptors.push('jwtInterceptor'); $httpProvider.interceptors.push('jwtInterceptor');

@ -69,9 +69,9 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
id: attachId, id: attachId,
}; };
const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref();
var url = var url =
window.location.origin + base +
baseHref() +
'api/websocket/attach?' + 'api/websocket/attach?' +
Object.keys(params) Object.keys(params)
.map((k) => k + '=' + params[k]) .map((k) => k + '=' + params[k])
@ -110,9 +110,9 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
id: data.Id, id: data.Id,
}; };
const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref();
var url = var url =
window.location.origin + base +
baseHref() +
'api/websocket/exec?' + 'api/websocket/exec?' +
Object.keys(params) Object.keys(params)
.map((k) => k + '=' + params[k]) .map((k) => k + '=' + params[k])

4
app/global.d.ts vendored

@ -18,3 +18,7 @@ declare module 'axios-progress-bar' {
instance?: AxiosInstance instance?: AxiosInstance
): void; ): void;
} }
interface Window {
ddExtension: boolean;
}

@ -7,9 +7,16 @@
<meta name="author" content="<%= author %>" /> <meta name="author" content="<%= author %>" />
<base id="base" /> <base id="base" />
<script> <script>
var path = window.location.pathname.replace(/^\/+|\/+$/g, ''); if (window.origin == 'file://') {
var basePath = path ? '/' + path + '/' : '/'; // we are loading the app from a local file as in docker extension
document.getElementById('base').href = basePath; document.getElementById('base').href = 'http://localhost:9000/';
window.ddExtension = true;
} else {
var path = window.location.pathname.replace(/^\/+|\/+$/g, '');
var basePath = path ? '/' + path + '/' : '/';
document.getElementById('base').href = basePath;
}
</script> </script>
<!-- HTML5 shim, for IE6-8 support of HTML5 elements --> <!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
@ -62,9 +69,12 @@
</div> </div>
<!-- Main Content --> <!-- Main Content -->
<div id="view" ui-view="content" ng-if="!applicationState.loading"></div> </div <div id="view" ui-view="content" ng-if="!applicationState.loading"></div>
><!-- End Page Content --> </div </div>
><!-- End Content Wrapper --> </div <!-- End Page Content -->
><!-- End Page Wrapper --> </div>
</body></html <!-- End Content Wrapper -->
> </div>
<!-- End Page Wrapper -->
</body>
</html>

@ -93,11 +93,13 @@ export default class KubectlShellController {
const wsProtocol = this.$window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = this.$window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const path = baseHref() + 'api/websocket/kubernetes-shell'; const path = baseHref() + 'api/websocket/kubernetes-shell';
const base = path.startsWith('http') ? path.replace(/^https?:\/\//i, '') : window.location.host + path;
const queryParams = Object.entries(params) const queryParams = Object.entries(params)
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join('&'); .join('&');
const url = `${wsProtocol}${window.location.host}${path}?${queryParams}`;
const url = `${wsProtocol}${base}?${queryParams}`;
Terminal.applyAddon(fit); Terminal.applyAddon(fit);
this.state.shell.socket = new WebSocket(url); this.state.shell.socket = new WebSocket(url);
this.state.shell.term = new Terminal(); this.state.shell.term = new Terminal();

@ -58,9 +58,10 @@ class KubernetesApplicationConsoleController {
command: this.state.command, command: this.state.command,
}; };
const base = window.location.origin.startsWith('http') ? `${window.location.origin}${baseHref()}` : baseHref();
let url = let url =
window.location.origin + base +
baseHref() +
'api/websocket/pod?' + 'api/websocket/pod?' +
Object.keys(params) Object.keys(params)
.map((k) => k + '=' + params[k]) .map((k) => k + '=' + params[k])

@ -2,7 +2,7 @@ export default class HeaderContentController {
/* @ngInject */ /* @ngInject */
constructor(Authentication) { constructor(Authentication) {
this.Authentication = Authentication; this.Authentication = Authentication;
this.display = !window.ddExtension;
this.username = null; this.username = null;
} }

@ -1,11 +1,11 @@
<div class="breadcrumb-links"> <div class="breadcrumb-links">
<div class="pull-left" ng-transclude></div> <div class="pull-left" ng-transclude></div>
<div class="pull-right" ng-if="$ctrl.username"> <div class="pull-right" ng-if="$ctrl.username && $ctrl.display">
<a ui-sref="portainer.account" style="margin-right: 5px"> <a ui-sref="portainer.account" style="margin-right: 5px">
<u><i class="fa fa-wrench" aria-hidden="true"></i> my account </u> <u> <i class="fa fa-wrench" aria-hidden="true"></i> my account </u>
</a> </a>
<a ui-sref="portainer.logout({performApiLogout: true})" class="text-danger" style="margin-right: 25px" data-cy="template-logoutButton"> <a ui-sref="portainer.logout({performApiLogout: true})" class="text-danger" style="margin-right: 25px" data-cy="template-logoutButton">
<u><i class="fa fa-sign-out-alt" aria-hidden="true"></i> log out</u> <u> <i class="fa fa-sign-out-alt" aria-hidden="true"></i> log out</u>
</a> </a>
</div> </div>
</div> </div>

@ -15,7 +15,7 @@ export function HeaderContent({ children }: PropsWithChildren<unknown>) {
return ( return (
<div className="breadcrumb-links"> <div className="breadcrumb-links">
<div className="pull-left">{children}</div> <div className="pull-left">{children}</div>
{user && ( {user && !window.ddExtension && (
<div className={clsx('pull-right', styles.userLinks)}> <div className={clsx('pull-right', styles.userLinks)}>
<Link to="portainer.account" className={styles.link}> <Link to="portainer.account" className={styles.link}>
<i <i

@ -2,7 +2,7 @@ export default class HeaderTitle {
/* @ngInject */ /* @ngInject */
constructor(Authentication) { constructor(Authentication) {
this.Authentication = Authentication; this.Authentication = Authentication;
this.display = !window.ddExtension;
this.username = null; this.username = null;
} }

@ -1,5 +1,8 @@
<div class="page white-space-normal"> <div class="page white-space-normal">
{{ $ctrl.titleText }} {{ $ctrl.titleText }}
<span class="header_title_content" ng-transclude></span> <span class="header_title_content" ng-transclude></span>
<span class="pull-right user-box" ng-if="$ctrl.username"> <i class="fa fa-user-circle" aria-hidden="true"></i> {{ $ctrl.username }} </span> <span class="pull-right user-box" ng-if="$ctrl.username && $ctrl.display">
<i class="fa fa-user-circle" aria-hidden="true"></i>
{{ $ctrl.username }}
</span>
</div> </div>

@ -17,9 +17,10 @@ export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
<div className="page white-space-normal"> <div className="page white-space-normal">
{title} {title}
<span className="header_title_content">{children}</span> <span className="header_title_content">{children}</span>
{user && ( {user && !window.ddExtension && (
<span className="pull-right user-box"> <span className="pull-right user-box">
<i className="fa fa-user-circle" aria-hidden="true" /> {user.Username} <i className="fa fa-user-circle" aria-hidden="true" />
{user.Username}
</span> </span>
)} )}
</div> </div>

@ -8,16 +8,24 @@ angular.module('portainer.app').factory('WebhookHelper', [
'use strict'; 'use strict';
var helper = {}; var helper = {};
let base;
const protocol = $location.protocol().toLowerCase(); const protocol = $location.protocol().toLowerCase();
const port = $location.port();
const displayPort = (protocol === 'http' && port === 80) || (protocol === 'https' && port === 443) ? '' : ':' + port; if (protocol !== 'file') {
const host = $location.host();
const port = $location.port();
const displayPort = (protocol === 'http' && port === 80) || (protocol === 'https' && port === 443) ? '' : ':' + port;
base = `${protocol}://${host}${displayPort}${baseHref()}`;
} else {
base = baseHref();
}
helper.returnWebhookUrl = function (token) { helper.returnWebhookUrl = function (token) {
return `${protocol}://${$location.host()}${displayPort}${baseHref()}${API_ENDPOINT_WEBHOOKS}/${token}`; return `${base}${API_ENDPOINT_WEBHOOKS}/${token}`;
}; };
helper.returnStackWebhookUrl = function (token) { helper.returnStackWebhookUrl = function (token) {
return `${protocol}://${$location.host()}${displayPort}${baseHref()}${API_ENDPOINT_STACKS}/webhooks/${token}`; return `${base}${API_ENDPOINT_STACKS}/webhooks/${token}`;
}; };
return helper; return helper;

@ -31,9 +31,11 @@ angular.module('portainer.app').factory('EndpointStatusInterceptor', [
} }
function responseErrorInterceptor(rejection) { function responseErrorInterceptor(rejection) {
var url = rejection.config.url; if (rejection.config) {
if ((rejection.status === 502 || rejection.status === 503 || rejection.status === -1) && canBeOffline(url) && !EndpointProvider.offlineMode()) { var url = rejection.config.url;
EndpointProvider.setOfflineMode(true); if ((rejection.status === 502 || rejection.status === 503 || rejection.status === -1) && canBeOffline(url) && !EndpointProvider.offlineMode()) {
EndpointProvider.setOfflineMode(true);
}
} }
return $q.reject(rejection); return $q.reject(rejection);
} }

@ -1,5 +1,8 @@
import { clear as clearSessionStorage } from './session-storage'; import { clear as clearSessionStorage } from './session-storage';
const DEFAULT_USER = 'admin';
const DEFAULT_PASSWORD = 'K7yJPP5qNK4hf1QsRnfV';
angular.module('portainer.app').factory('Authentication', [ angular.module('portainer.app').factory('Authentication', [
'$async', '$async',
'$state', '$state',
@ -28,12 +31,14 @@ angular.module('portainer.app').factory('Authentication', [
async function initAsync() { async function initAsync() {
try { try {
const jwt = LocalStorage.getJWT(); const jwt = LocalStorage.getJWT();
if (jwt) { if (!jwt || jwtHelper.isTokenExpired(jwt)) {
await setUser(jwt); return tryAutoLoginExtension();
} }
return !!jwt; await setUser(jwt);
return true;
} catch (error) { } catch (error) {
return false; console.log('Unable to initialize authentication service', error);
return tryAutoLoginExtension();
} }
} }
@ -47,6 +52,7 @@ angular.module('portainer.app').factory('Authentication', [
EndpointProvider.clean(); EndpointProvider.clean();
LocalStorage.cleanAuthData(); LocalStorage.cleanAuthData();
LocalStorage.storeLoginStateUUID(''); LocalStorage.storeLoginStateUUID('');
tryAutoLoginExtension();
} }
function logout(performApiLogout) { function logout(performApiLogout) {
@ -59,7 +65,15 @@ angular.module('portainer.app').factory('Authentication', [
async function OAuthLoginAsync(code) { async function OAuthLoginAsync(code) {
const response = await OAuth.validate({ code: code }).$promise; const response = await OAuth.validate({ code: code }).$promise;
await setUser(response.jwt); const jwt = setJWTFromResponse(response);
await setUser(jwt);
}
function setJWTFromResponse(response) {
const jwt = response.jwt;
LocalStorage.storeJWT(jwt);
return response.jwt;
} }
function OAuthLogin(code) { function OAuthLogin(code) {
@ -68,7 +82,8 @@ angular.module('portainer.app').factory('Authentication', [
async function loginAsync(username, password) { async function loginAsync(username, password) {
const response = await Auth.login({ username: username, password: password }).$promise; const response = await Auth.login({ username: username, password: password }).$promise;
await setUser(response.jwt); const jwt = setJWTFromResponse(response);
await setUser(jwt);
} }
function login(username, password) { function login(username, password) {
@ -77,7 +92,7 @@ angular.module('portainer.app').factory('Authentication', [
function isAuthenticated() { function isAuthenticated() {
var jwt = LocalStorage.getJWT(); var jwt = LocalStorage.getJWT();
return jwt && !jwtHelper.isTokenExpired(jwt); return !!jwt && !jwtHelper.isTokenExpired(jwt);
} }
function getUserDetails() { function getUserDetails() {
@ -96,7 +111,6 @@ angular.module('portainer.app').factory('Authentication', [
} }
async function setUser(jwt) { async function setUser(jwt) {
LocalStorage.storeJWT(jwt);
var tokenPayload = jwtHelper.decodeToken(jwt); var tokenPayload = jwtHelper.decodeToken(jwt);
user.username = tokenPayload.username; user.username = tokenPayload.username;
user.ID = tokenPayload.id; user.ID = tokenPayload.id;
@ -105,11 +119,16 @@ angular.module('portainer.app').factory('Authentication', [
await setUserTheme(); await setUserTheme();
} }
function isAdmin() { function tryAutoLoginExtension() {
if (user.role === 1) { if (!window.ddExtension) {
return true; return false;
} }
return false;
return login(DEFAULT_USER, DEFAULT_PASSWORD);
}
function isAdmin() {
return !!user && user.role === 1;
} }
return service; return service;

@ -90,8 +90,16 @@ angular
}; };
$scope.setDefaultPortainerInstanceURL = function () { $scope.setDefaultPortainerInstanceURL = function () {
const baseHREF = baseHref(); let url;
$scope.formValues.URL = window.location.origin + (baseHREF !== '/' ? baseHREF : '');
if (window.location.origin.startsWith('http')) {
const path = baseHref() !== '/' ? path : '';
url = `${window.location.origin}${path}`;
} else {
url = baseHref().replace(/\/$/, '');
}
$scope.formValues.URL = url;
}; };
$scope.resetEndpointURL = function () { $scope.resetEndpointURL = function () {

@ -19,7 +19,7 @@ angular.module('portainer.app').controller('MainController', [
$scope.$watch($scope.getWidth, function (newValue) { $scope.$watch($scope.getWidth, function (newValue) {
if (newValue >= mobileView) { if (newValue >= mobileView) {
const toggleValue = LocalStorage.getToolbarToggle(); const toggleValue = LocalStorage.getToolbarToggle();
$scope.toggle = typeof toggleValue === 'boolean' ? toggleValue : true; $scope.toggle = typeof toggleValue === 'boolean' ? toggleValue : !window.ddExtension;
} else { } else {
$scope.toggle = false; $scope.toggle = false;
} }

@ -21,7 +21,10 @@
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<label for="toggle_logo" class="control-label text-left"> Use custom logo </label> <label for="toggle_logo" class="control-label text-left"> Use custom logo </label>
<label class="switch" style="margin-left: 20px"> <input type="checkbox" name="toggle_logo" ng-model="formValues.customLogo" /><i></i> </label> <label class="switch" style="margin-left: 20px">
<input type="checkbox" name="toggle_logo" ng-model="formValues.customLogo" />
<i></i>
</label>
</div> </div>
</div> </div>
<div ng-if="formValues.customLogo"> <div ng-if="formValues.customLogo">
@ -40,7 +43,10 @@
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<label for="toggle_enableTelemetry" class="control-label text-left"> Allow the collection of anonymous statistics </label> <label for="toggle_enableTelemetry" class="control-label text-left"> Allow the collection of anonymous statistics </label>
<label class="switch" style="margin-left: 20px"> <input type="checkbox" name="toggle_enableTelemetry" ng-model="formValues.enableTelemetry" /><i></i> </label> <label class="switch" style="margin-left: 20px">
<input type="checkbox" name="toggle_enableTelemetry" ng-model="formValues.enableTelemetry" />
<i></i>
</label>
</div> </div>
<div class="col-sm-12 text-muted small" style="margin-top: 10px"> <div class="col-sm-12 text-muted small" style="margin-top: 10px">
You can find more information about this in our You can find more information about this in our
@ -128,7 +134,7 @@
</div> </div>
</div> </div>
<ssl-certificate-settings></ssl-certificate-settings> <ssl-certificate-settings ng-show="$ctrl.showHTTPS"></ssl-certificate-settings>
<div class="row"> <div class="row">
<div class="col-sm-12"> <div class="col-sm-12">
@ -149,7 +155,7 @@
<input type="text" class="form-control" id="header_value" ng-model="formValues.labelValue" placeholder="e.g. bar" /> <input type="text" class="form-control" id="header_value" ng-model="formValues.labelValue" placeholder="e.g. bar" />
</div> </div>
<div class="col-sm-12 col-md-2 margin-sm-top"> <div class="col-sm-12 col-md-2 margin-sm-top">
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="!formValues.labelName"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add filter</button> <button type="submit" class="btn btn-primary btn-sm" ng-disabled="!formValues.labelName"> <i class="fa fa-plus space-right" aria-hidden="true"></i>Add filter</button>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -166,11 +172,11 @@
<tr ng-repeat="label in settings.BlackListedLabels"> <tr ng-repeat="label in settings.BlackListedLabels">
<td>{{ label.name }}</td> <td>{{ label.name }}</td>
<td>{{ label.value }}</td> <td>{{ label.value }}</td>
<td <td>
><button type="button" class="btn btn-danger btn-xs" ng-click="removeFilteredContainerLabel($index)" <button type="button" class="btn btn-danger btn-xs" ng-click="removeFilteredContainerLabel($index)">
><i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove</button <i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove</button
></td >
> </td>
</tr> </tr>
<tr ng-if="settings.BlackListedLabels.length === 0"> <tr ng-if="settings.BlackListedLabels.length === 0">
<td colspan="3" class="text-center text-muted">No filter available.</td> <td colspan="3" class="text-center text-muted">No filter available.</td>
@ -208,8 +214,8 @@
checked="formValues.scheduleAutomaticBackups" checked="formValues.scheduleAutomaticBackups"
label-class="'col-sm-2'" label-class="'col-sm-2'"
on-change="(onToggleAutoBackups)" on-change="(onToggleAutoBackups)"
></por-switch-field ></por-switch-field>
></div> </div>
</div> </div>
<!-- !Schedule automatic backups --> <!-- !Schedule automatic backups -->
<!-- Cron rule --> <!-- Cron rule -->
@ -311,7 +317,8 @@
<label for="password_protect" class="col-sm-1 control-label text-left">Password protect</label> <label for="password_protect" class="col-sm-1 control-label text-left">Password protect</label>
<div class="col-sm-1"> <div class="col-sm-1">
<label class="switch" data-cy="settings-s3PasswordToggle"> <label class="switch" data-cy="settings-s3PasswordToggle">
<input type="checkbox" id="password_protect_s3" name="password_protect_s3" ng-model="formValues.passwordProtectS3" disabled /><i></i> <input type="checkbox" id="password_protect_s3" name="password_protect_s3" ng-model="formValues.passwordProtectS3" disabled />
<i></i>
</label> </label>
</div> </div>
</div> </div>
@ -342,7 +349,7 @@
limited-feature-disabled limited-feature-disabled
limited-feature-class="limited-be" limited-feature-class="limited-be"
> >
<span><i class="fa fa-upload" aria-hidden="true"></i> Export backup</span> <span> <i class="fa fa-upload" aria-hidden="true"></i> Export backup</span>
</button> </button>
</div> </div>
</div> </div>
@ -370,7 +377,8 @@
<label for="password_protect" class="col-sm-1 control-label text-left">Password protect</label> <label for="password_protect" class="col-sm-1 control-label text-left">Password protect</label>
<div class="col-sm-1"> <div class="col-sm-1">
<label class="switch" data-cy="settings-passwordProtectLocal"> <label class="switch" data-cy="settings-passwordProtectLocal">
<input type="checkbox" id="password_protect" name="password_protect" ng-model="formValues.passwordProtect" /><i></i> <input type="checkbox" id="password_protect" name="password_protect" ng-model="formValues.passwordProtect" />
<i></i>
</label> </label>
</div> </div>
</div> </div>

@ -45,6 +45,7 @@ angular.module('portainer.app').controller('SettingsController', [
], ],
backupInProgress: false, backupInProgress: false,
featureLimited: false, featureLimited: false,
showHTTPS: !window.ddExtension,
}; };
$scope.BACKUP_FORM_TYPES = { S3: 's3', FILE: 'file' }; $scope.BACKUP_FORM_TYPES = { S3: 's3', FILE: 'file' };

@ -56,6 +56,7 @@
<sidebar-section ng-if="isAdmin || isTeamLeader" title="Settings"> <sidebar-section ng-if="isAdmin || isTeamLeader" title="Settings">
<sidebar-menu <sidebar-menu
ng-show="display"
icon-class="fa-users fa-fw" icon-class="fa-users fa-fw"
label="Users" label="Users"
path="portainer.users" path="portainer.users"
@ -95,7 +96,12 @@
is-sidebar-open="toggle" is-sidebar-open="toggle"
children-paths="['portainer.settings.authentication', 'portainer.settings.edgeCompute']" children-paths="['portainer.settings.authentication', 'portainer.settings.edgeCompute']"
> >
<sidebar-menu-item path="portainer.settings.authentication" class-name="sidebar-sublist" data-cy="portainerSidebar-authentication" title="Authentication" <sidebar-menu-item
path="portainer.settings.authentication"
class-name="sidebar-sublist"
data-cy="portainerSidebar-authentication"
title="Authentication"
ng-show="display"
>Authentication</sidebar-menu-item >Authentication</sidebar-menu-item
> >
<sidebar-menu-item path="portainer.settings.edgeCompute" class-name="sidebar-sublist" data-cy="portainerSidebar-edge-compute" title="Edge Compute" <sidebar-menu-item path="portainer.settings.edgeCompute" class-name="sidebar-sublist" data-cy="portainerSidebar-edge-compute" title="Edge Compute"

@ -3,6 +3,7 @@ angular.module('portainer.app').controller('SidebarController', SidebarControlle
function SidebarController($rootScope, $scope, $transitions, StateManager, Notifications, Authentication, UserService, EndpointProvider) { function SidebarController($rootScope, $scope, $transitions, StateManager, Notifications, Authentication, UserService, EndpointProvider) {
$scope.applicationState = StateManager.getState(); $scope.applicationState = StateManager.getState();
$scope.endpointState = EndpointProvider.endpoint(); $scope.endpointState = EndpointProvider.endpoint();
$scope.display = !window.ddExtension;
function checkPermissions(memberships) { function checkPermissions(memberships) {
var isLeader = false; var isLeader = false;

@ -0,0 +1,29 @@
# Makefile for development purpose
.PHONY: local build
local: clean build install
remote: clean build-remote install
ORG=portainer
VERSION=0.0.0
IMAGE_NAME=$(ORG)/portainer-docker-extension
TAGGED_IMAGE_NAME=$(IMAGE_NAME):$(VERSION)
clean:
-docker extension remove $(IMAGE_NAME)
-docker rmi $(IMAGE_NAME):$(VERSION)
build:
docker buildx build -f build/linux/Dockerfile --load --build-arg TAG=$(VERSION) --build-arg PORTAINER_IMAGE_NAME=$(IMAGE_NAME) --tag=$(TAGGED_IMAGE_NAME) .
build-remote:
docker buildx build -f build/linux/Dockerfile --push --builder=buildx-multi-arch --platform=windows/amd64,linux/amd64,linux/arm64 --build-arg TAG=$(VERSION) --build-arg PORTAINER_IMAGE_NAME=$(IMAGE_NAME) --tag=$(TAGGED_IMAGE_NAME) .
install:
docker extension install $(TAGGED_IMAGE_NAME)
multiarch:
docker buildx create --name=buildx-multi-arch --driver=docker-container --driver-opt=network=host
portainer:
yarn build

@ -0,0 +1,18 @@
version: '3'
services:
portainer:
image: ${DESKTOP_PLUGIN_IMAGE}
command: ['--admin-password', '$$$$2y$$$$05$$$$bsb.XmF.r2DU6/9oVUaDxu3.Lxhmg1R8M0NMLK6JJKUiqUcaNjvdu']
restart: unless-stopped
security_opt:
- no-new-privileges:true
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- portainer_data:/data
ports:
- 127.0.0.1:8000:8000
- 127.0.0.1:9000:9000
- 127.0.0.1:9443:9443
volumes:
portainer_data:

@ -0,0 +1,15 @@
{
"name": "Portainer",
"icon": "portainer.svg",
"vm": {
"composefile": "docker-compose.yml",
"exposes": { "socket": "docker.sock" }
},
"ui": {
"dashboard-tab": {
"title": "Portainer",
"root": "/public",
"src": "index.html"
}
}
}

@ -0,0 +1 @@
<svg height="2500" viewBox=".16 0 571.71 800" width="1788" xmlns="http://www.w3.org/2000/svg"><g fill="#13bef9"><path d="m190.83 175.88h-12.2v63.2h12.2zm52.47 0h-12.2v63.2h12.2zm71.69-120.61-12.5-21.68-208.67 120.61 12.5 21.68z"/><path d="m313.77 55.27 12.51-21.68 208.67 120.61-12.51 21.68z"/><path d="m571.87 176.18v-25.03h-571.71v25.03z"/><path d="m345.5 529.77v-370.99h25.02v389.01c-6.71-7.64-15.26-13.13-25.02-18.02zm-42.71-6.41v-523.36h25.02v526.41c-7.02-3.36-24.1-3.05-25.02-3.05zm-237.04 52.21c-30.51-22.59-50.64-58.62-50.64-99.54 0-21.68 5.79-43.05 16.47-61.68h213.55c10.98 18.63 16.48 40 16.48 61.68 0 18.93-2.44 36.64-10.07 52.52-16.17-15.57-39.97-22.29-64.07-22.29-42.71 0-79.32 26.56-88.77 66.26-3.36-.31-5.49-.61-8.85-.61-8.24.3-16.17 1.53-24.1 3.66z" fill-rule="evenodd"/><path d="m170.69 267.18h-64.67v65.03h64.67zm-72.91 0h-64.67v65.03h64.67zm0 72.36h-64.67v65.04h64.67zm72.91 0h-64.67v65.04h64.67zm72.61 0h-64.67v65.04h64.67zm0-107.17h-64.67v65.03h64.67z"/><path d="m109.37 585.34c8.85-37.55 42.71-65.65 82.98-65.65 25.94 0 49.12 11.61 64.99 29.93 13.72-9.47 30.2-14.96 48.2-14.96 46.98 0 85.11 38.16 85.11 85.19 0 9.77-1.52 18.93-4.57 27.78 10.37 14.05 16.78 31.76 16.78 50.69 0 47.02-38.14 85.19-85.12 85.19-20.75 0-39.66-7.33-54.3-19.54-15.56 21.68-40.88 36.03-69.56 36.03-32.95 0-61.63-18.93-75.96-46.41-5.8 1.22-11.6 1.83-17.7 1.83-46.98 0-85.42-38.17-85.42-85.19s38.14-85.19 85.42-85.19c3.05-.31 6.1-.31 9.15.3z" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@ -1,6 +1,13 @@
FROM portainer/base FROM portainer/base
LABEL org.opencontainers.image.title="Portainer" \
org.opencontainers.image.description="Rich container management experience using Portainer." \
org.opencontainers.image.vendor="Portainer.io" \
com.docker.desktop.extension.api.version=">= 0.2.2" \
com.docker.desktop.extension.icon=https://portainer-io-assets.sfo2.cdn.digitaloceanspaces.com/logos/portainer.png
COPY dist / COPY dist /
COPY build/docker-extension /
VOLUME /data VOLUME /data
WORKDIR / WORKDIR /

@ -32,6 +32,7 @@
"dev:client": "grunt clean:client && webpack-dev-server --config=./webpack/webpack.develop.js", "dev:client": "grunt clean:client && webpack-dev-server --config=./webpack/webpack.develop.js",
"dev:client:prod": "grunt clean:client && webpack-dev-server --config=./webpack/webpack.production.js", "dev:client:prod": "grunt clean:client && webpack-dev-server --config=./webpack/webpack.production.js",
"dev:nodl": "grunt clean:server && grunt clean:client && grunt build:server && grunt start:client", "dev:nodl": "grunt clean:server && grunt clean:client && grunt build:server && grunt start:client",
"dev:extension": "grunt build && make local -f build/docker-extension/Makefile",
"start:toolkit": "grunt start:toolkit", "start:toolkit": "grunt start:toolkit",
"build:server:offline": "cd ./api/cmd/portainer && CGO_ENABLED=0 go build --installsuffix cgo --ldflags '-s' && mv -f portainer ../../../dist/portainer", "build:server:offline": "cd ./api/cmd/portainer && CGO_ENABLED=0 go build --installsuffix cgo --ldflags '-s' && mv -f portainer ../../../dist/portainer",
"clean:all": "grunt clean:all", "clean:all": "grunt clean:all",

Loading…
Cancel
Save