refactor(sidebar): migrate sidebar to react [EE-2907] (#6725)

* refactor(sidebar): migrate sidebar to react [EE-2907]

fixes [EE-2907]

feat(sidebar): show label for help

fix(sidebar): apply changes from ddExtension

fix(sidebar): resolve conflicts

style(ts): add explanation for ddExtension

fix(sidebar): use enum for status

refactor(sidebar): rename to EdgeComputeSidebar

refactor(sidebar): removed the need of `ident` prop

style(sidebar): add ref for mobile breakpoint

refactor(app): document testing props

refactor(sidebar): use single sidebar item

refactor(sidebar): use section for nav

refactor(sidebar): rename sidebarlink to link

refactor(sidebar): memoize menu paths

fix(kubectl-shell): infinite loop on hooks dependencies

refactor(sidebar): use authorized element

feat(k8s/shell): track open shell

refactor(k8s/shell): remove memoization

refactor(settings): move settings queries to queries

fix(sidebar): close sidebar on mobile

refactor(settings): use mutation helpers

refactor(sidebar): remove memo

refactor(sidebar): rename sidebar item for storybook

refactor(sidebar): move to react

gprefactor(sidebar): remove dependence on EndProvider

feat(environments): rename settings type

feat(kube): move kubeconfig button

fix(sidebar): open submenus

fix(sidebar): open on expand

fix(sibebar): show kube shell correctly

* fix(sidebar): import from react component

* chore(tests): fix missing prop
pull/7084/head
Chaim Lev-Ari 2022-06-23 10:25:56 +03:00 committed by GitHub
parent f78a6568a6
commit 84611a90a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
118 changed files with 2284 additions and 1648 deletions

View File

@ -7,7 +7,14 @@ export function onStartupAngular($rootScope, $state, $interval, LocalStorage, En
EndpointProvider.initialize(); EndpointProvider.initialize();
$rootScope.$state = $state; $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 // Workaround to prevent the loading bar from going backward
// https://github.com/chieffancypants/angular-loading-bar/issues/273 // https://github.com/chieffancypants/angular-loading-bar/issues/273

View File

@ -809,11 +809,12 @@ json-tree .branch-preview {
} }
/* !spinkit override */ /* !spinkit override */
.kubectl-shell { /* uib-typeahead override */
display: block; #scrollable-dropdown-menu .dropdown-menu {
text-align: center; max-height: 300px;
padding-bottom: 5px; overflow-y: auto;
} }
/* !uib-typeahead override */
.no-margin { .no-margin {
margin: 0 !important; margin: 0 !important;

View File

@ -15,39 +15,6 @@
} }
} }
/**
* 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 { .loading {
width: 40px; width: 40px;
height: 40px; height: 40px;
@ -131,16 +98,8 @@ body {
padding-left: 70px; padding-left: 70px;
height: 100%; height: 100%;
} }
#sidebar-wrapper {
margin-left: -150px; #page-wrapper {
left: -30px;
width: 250px;
position: fixed;
height: 100%;
z-index: 999;
}
#page-wrapper,
#sidebar-wrapper {
transition: all 0.4s ease 0s; transition: all 0.4s ease 0s;
} }
.green { .green {

View File

@ -115,7 +115,6 @@
--bg-main-color: var(--white-color); --bg-main-color: var(--white-color);
--bg-body-color: var(--grey-9); --bg-body-color: var(--grey-9);
--bg-checkbox-border-color: var(--grey-49); --bg-checkbox-border-color: var(--grey-49);
--bg-sidebar-color: var(--grey-37);
--bg-sidebar-header-color: var(--grey-37); --bg-sidebar-header-color: var(--grey-37);
--bg-widget-color: var(--white-color); --bg-widget-color: var(--white-color);
--bg-widget-header-color: var(--grey-10); --bg-widget-header-color: var(--grey-10);
@ -152,7 +151,6 @@
--bg-row-header-color: var(--white-color); --bg-row-header-color: var(--white-color);
--bg-image-multiselect-button: linear-gradient(var(--white-color), var(--grey-17)); --bg-image-multiselect-button: linear-gradient(var(--white-color), var(--grey-17));
--bg-multiselect-checkbox-color: var(--white-color); --bg-multiselect-checkbox-color: var(--white-color);
--bg-sidebar-wrapper-color: var(--blue-5);
--bg-panel-body-color: var(--white-color); --bg-panel-body-color: var(--white-color);
--bg-codemirror-color: var(--white-color); --bg-codemirror-color: var(--white-color);
--bg-codemirror-selected-color: var(--grey-22); --bg-codemirror-selected-color: var(--grey-22);
@ -223,14 +221,11 @@
--text-ui-select-hover-color: var(--grey-28); --text-ui-select-hover-color: var(--grey-28);
--text-summary-color: var(--black-color); --text-summary-color: var(--black-color);
--text-tooltip-color: var(--white-color); --text-tooltip-color: var(--white-color);
--text-sidebar-list-color: var(--grey-56);
--text-rzslider-color: var(--grey-36); --text-rzslider-color: var(--grey-36);
--text-rzslider-limit-color: var(--grey-36); --text-rzslider-limit-color: var(--grey-36);
--text-daterangepicker-end-date: var(--grey-57); --text-daterangepicker-end-date: var(--grey-57);
--text-daterangepicker-in-range: var(--black-color); --text-daterangepicker-in-range: var(--black-color);
--text-daterangepicker-active: var(--white-color); --text-daterangepicker-active: var(--white-color);
--text-tooltip-color: var(--grey-6);
--text-input-autofill-color: var(--black-color); --text-input-autofill-color: var(--black-color);
--text-button-hover-color: var(--grey-6); --text-button-hover-color: var(--grey-6);
--text-small-select-color: var(--grey-25); --text-small-select-color: var(--grey-25);
@ -273,7 +268,6 @@
--border-blocklist: var(--ui-grey-1); --border-blocklist: var(--ui-grey-1);
--border-widget: var(--ui-grey-1); --border-widget: var(--ui-grey-1);
--hover-sidebar-color: var(--grey-37);
--shadow-box-color: 0 3px 10px -2px var(--grey-50); --shadow-box-color: 0 3px 10px -2px var(--grey-50);
--shadow-boxselector-color: 0 3px 10px -2px var(--grey-50); --shadow-boxselector-color: 0 3px 10px -2px var(--grey-50);
--blue-color: var(--blue-13); --blue-color: var(--blue-13);
@ -307,7 +301,6 @@
--bg-main-color: var(--grey-2); --bg-main-color: var(--grey-2);
--bg-body-color: var(--grey-2); --bg-body-color: var(--grey-2);
--bg-checkbox-border-color: var(--grey-8); --bg-checkbox-border-color: var(--grey-8);
--bg-sidebar-color: var(--grey-3);
--bg-widget-color: var(--grey-1); --bg-widget-color: var(--grey-1);
--bg-widget-header-color: var(--grey-1); --bg-widget-header-color: var(--grey-1);
--bg-widget-table-color: var(--grey-1); --bg-widget-table-color: var(--grey-1);
@ -345,7 +338,6 @@
--bg-multiselect-button-color: var(--grey-3); --bg-multiselect-button-color: var(--grey-3);
--bg-image-multiselect-button: none !important; --bg-image-multiselect-button: none !important;
--bg-multiselect-checkbox-color: var(--grey-3); --bg-multiselect-checkbox-color: var(--grey-3);
--bg-sidebar-wrapper-color: var(--grey-1);
--bg-panel-body-color: var(--grey-1); --bg-panel-body-color: var(--grey-1);
--bg-boxselector-wrapper-disabled-color: var(--grey-39); --bg-boxselector-wrapper-disabled-color: var(--grey-39);
--bg-codemirror-selected-color: var(--grey-3); --bg-codemirror-selected-color: var(--grey-3);
@ -411,7 +403,6 @@
--text-summary-color: var(--white-color); --text-summary-color: var(--white-color);
--text-multiselect-button-color: var(--white-color); --text-multiselect-button-color: var(--white-color);
--text-multiselect-item-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-boxselector-wrapper-color: var(--white-color);
--text-daterangepicker-end-date: var(--grey-7); --text-daterangepicker-end-date: var(--grey-7);
--text-daterangepicker-in-range: var(--white-color); --text-daterangepicker-in-range: var(--white-color);
@ -459,7 +450,6 @@
--border-blocklist: var(--ui-grey-3); --border-blocklist: var(--ui-grey-3);
--border-widget: var(--ui-grey-3); --border-widget: var(--ui-grey-3);
--hover-sidebar-color: var(--grey-3);
--blue-color: var(--blue-2); --blue-color: var(--blue-2);
--button-close-color: var(--white-color); --button-close-color: var(--white-color);
--button-opacity: 0.6; --button-opacity: 0.6;
@ -491,7 +481,6 @@
--bg-main-color: var(--black-color); --bg-main-color: var(--black-color);
--bg-body-color: var(--black-color); --bg-body-color: var(--black-color);
--bg-checkbox-border-color: var(--grey-8); --bg-checkbox-border-color: var(--grey-8);
--bg-sidebar-color: var(--black-color);
--bg-widget-color: var(--black-color); --bg-widget-color: var(--black-color);
--bg-widget-header-color: var(--black-color); --bg-widget-header-color: var(--black-color);
--bg-widget-table-color: var(--black-color); --bg-widget-table-color: var(--black-color);
@ -503,7 +492,6 @@
--bg-dropdown-menu-color: var(--black-color); --bg-dropdown-menu-color: var(--black-color);
--bg-codemirror-selected-color: var(--grey-3); --bg-codemirror-selected-color: var(--grey-3);
--bg-row-header-color: var(--black-color); --bg-row-header-color: var(--black-color);
--bg-sidebar-wrapper-color: var(--black-color);
--bg-motd-body-color: var(--black-color); --bg-motd-body-color: var(--black-color);
--bg-blocklist-hover-color: var(--black-color); --bg-blocklist-hover-color: var(--black-color);
--bg-blocklist-item-selected-color: var(--black-color); --bg-blocklist-item-selected-color: var(--black-color);
@ -585,7 +573,6 @@
--text-daterangepicker-end-date: var(--grey-7); --text-daterangepicker-end-date: var(--grey-7);
--text-daterangepicker-in-range: var(--white-color); --text-daterangepicker-in-range: var(--white-color);
--text-daterangepicker-active: var(--white-color); --text-daterangepicker-active: var(--white-color);
--text-sidebar-list-color: var(--white-color);
--text-ui-select-color: var(--white-color); --text-ui-select-color: var(--white-color);
--text-btn-default-color: var(--white-color); --text-btn-default-color: var(--white-color);
--text-json-tree-color: var(--white-color); --text-json-tree-color: var(--white-color);
@ -634,8 +621,6 @@
--border-blocklist: var(--white-color); --border-blocklist: var(--white-color);
--border-widget: var(--white-color); --border-widget: var(--white-color);
--hover-sidebar-color: var(--blue-9);
--hover-sidebar-color: var(--black-color);
--shadow-box-color: none; --shadow-box-color: none;
--shadow-boxselector-color: none; --shadow-boxselector-color: none;

View File

@ -1,36 +0,0 @@
import { r2a } from '@/react-tools/react2angular';
import { SidebarMenuItem } from '@/portainer/components/sidebar/SidebarMenuItem';
import type { EnvironmentId } from '@/portainer/environments/types';
interface Props {
environmentId: EnvironmentId;
}
export function AzureSidebar({ environmentId }: Props) {
return (
<>
<SidebarMenuItem
path="azure.dashboard"
pathParams={{ endpointId: environmentId }}
iconClass="fa-tachometer-alt fa-fw"
className="sidebar-list"
itemName="Dashboard"
data-cy="azureSidebar-dashboard"
>
Dashboard
</SidebarMenuItem>
<SidebarMenuItem
path="azure.containerinstances"
pathParams={{ endpointId: environmentId }}
iconClass="fa-cubes fa-fw"
className="sidebar-list"
itemName="ContainerInstances"
data-cy="azureSidebar-containerInstances"
>
Container instances
</SidebarMenuItem>
</>
);
}
export const AzureSidebarAngular = r2a(AzureSidebar, ['environmentId']);

View File

@ -1 +0,0 @@
export { AzureSidebar, AzureSidebarAngular } from './AzureSidebar';

View File

@ -1,6 +1,5 @@
import angular from 'angular'; import angular from 'angular';
import { AzureSidebarAngular } from './AzureSidebar/AzureSidebar';
import { DashboardViewAngular } from './Dashboard/DashboardView'; import { DashboardViewAngular } from './Dashboard/DashboardView';
import { containerInstancesModule } from './ContainerInstances'; import { containerInstancesModule } from './ContainerInstances';
import { reactModule } from './react'; import { reactModule } from './react';
@ -84,5 +83,4 @@ angular
$stateRegistryProvider.register(dashboard); $stateRegistryProvider.register(dashboard);
}, },
]) ])
.component('azureSidebar', AzureSidebarAngular)
.component('dashboardView', DashboardViewAngular); .component('dashboardView', DashboardViewAngular);

View File

@ -1,186 +0,0 @@
<sidebar-menu-item
path="docker.dashboard"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-tachometer-alt fa-fw"
class-name="sidebar-list"
data-cy="dockerSidebar-dashboard"
title="Dashboard"
>
Dashboard
</sidebar-menu-item>
<sidebar-menu
ng-if="!$ctrl.offlineMode"
label="App Templates"
icon-class="fa-rocket fa-fw"
path="docker.templates"
path-params="{ endpointId: $ctrl.endpointId }"
is-sidebar-open="$ctrl.isSidebarOpen"
children-paths="[]"
>
<sidebar-menu-item
path="docker.templates.custom"
path-params="{ endpointId: $ctrl.endpointId }"
class-name="sidebar-sublist"
data-cy="dockerSidebar-customTemplates"
title="Custom Templates"
>
Custom Templates
</sidebar-menu-item>
</sidebar-menu>
<sidebar-menu-item
ng-if="$ctrl.showStacks"
path="docker.stacks"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-th-list fa-fw"
class-name="sidebar-list"
data-cy="dockerSidebar-stacks"
title="Stacks"
>
Stacks
</sidebar-menu-item>
<sidebar-menu-item
ng-if="$ctrl.swarmManagement"
path="docker.services"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-list-alt fa-fw"
class-name="sidebar-list"
data-cy="dockerSidebar-services"
title="Services"
>
Services
</sidebar-menu-item>
<sidebar-menu-item
path="docker.containers"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-cubes fa-fw"
class-name="sidebar-list"
data-cy="dockerSidebar-containers"
title="Containers"
>
Containers
</sidebar-menu-item>
<sidebar-menu-item
path="docker.images"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-clone fa-fw"
class-name="sidebar-list"
data-cy="dockerSidebar-images"
title="Images"
>
Images
</sidebar-menu-item>
<sidebar-menu-item
path="docker.networks"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-sitemap fa-fw"
class-name="sidebar-list"
data-cy="dockerSidebar-networks"
title="Networks"
>
Networks
</sidebar-menu-item>
<sidebar-menu-item
path="docker.volumes"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-hdd fa-fw"
class-name="sidebar-list"
data-cy="dockerSidebar-volumes"
title="Volumes"
>
Volumes
</sidebar-menu-item>
<sidebar-menu-item
ng-if="$ctrl.endpointApiVersion >= 1.3 && $ctrl.swarmManagement"
path="docker.configs"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-file-code fa-fw"
class-name="sidebar-list"
data-cy="dockerSidebar-configs"
title="Configs"
>
Configs
</sidebar-menu-item>
<sidebar-menu-item
ng-if="$ctrl.endpointApiVersion >= 1.25 && $ctrl.swarmManagement"
path="docker.secrets"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-user-secret fa-fw"
class-name="sidebar-list"
data-cy="dockerSidebar-secrets"
title="Secrets"
>
Secrets
</sidebar-menu-item>
<sidebar-menu-item
ng-if="$ctrl.standaloneManagement && $ctrl.adminAccess && !$ctrl.offlineMode"
path="docker.events"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-history fa-fw"
class-name="sidebar-list"
data-cy="dockerSidebar-events"
title="Events"
>
Events
</sidebar-menu-item>
<sidebar-menu
ng-if="$ctrl.standaloneManagement"
label="Host"
icon-class="fa-th fa-fw"
path="docker.host"
path-params="{ endpointId: $ctrl.endpointId }"
is-sidebar-open="$ctrl.isSidebarOpen"
children-paths="['docker.registries', 'docker.registries.access', 'docker.featuresConfiguration']"
>
<sidebar-menu-item
ng-if="$ctrl.adminAccess"
authorization="PortainerEndpointUpdateSettings"
path="docker.featuresConfiguration"
path-params="{ endpointId: $ctrl.endpointId }"
class-name="sidebar-sublist"
data-cy="dockerSidebar-setup"
title="Setup"
>
Setup
</sidebar-menu-item>
<sidebar-menu-item path="docker.registries" path-params="{ endpointId: $ctrl.endpointId }" class-name="sidebar-sublist" data-cy="dockerSidebar-registries" title="Registries">
Registries
</sidebar-menu-item>
</sidebar-menu>
<sidebar-menu
ng-if="$ctrl.swarmManagement"
label="Swarm"
icon-class="fa-object-group fa-fw"
path="docker.swarm"
path-params="{ endpointId: $ctrl.endpointId }"
is-sidebar-open="$ctrl.isSidebarOpen"
children-paths="['docker.registries', 'docker.registries.access', 'docker.featuresConfiguration']"
>
<sidebar-menu-item
ng-if="$ctrl.adminAccess"
authorization="PortainerEndpointUpdateSettings"
path="docker.featuresConfiguration"
path-params="{ endpointId: $ctrl.endpointId }"
class-name="sidebar-sublist"
data-cy="swarmSidebar-setup"
title="Setup"
>
Setup
</sidebar-menu-item>
<sidebar-menu-item path="docker.registries" path-params="{ endpointId: $ctrl.endpointId }" class-name="sidebar-sublist" data-cy="swarmSidebar-registries" title="Registries">
Registries
</sidebar-menu-item>
</sidebar-menu>

View File

@ -1,15 +0,0 @@
angular.module('portainer.docker').component('dockerSidebar', {
templateUrl: './docker-sidebar.html',
bindings: {
isSidebarOpen: '<',
endpointApiVersion: '<',
swarmManagement: '<',
standaloneManagement: '<',
adminAccess: '<',
offlineMode: '<',
currentRouteName: '<',
endpointId: '<',
showStacks: '<',
},
});

View File

@ -66,7 +66,7 @@ export function NetworkContainersTable({
<td> <td>
<Authorized authorizations="DockerNetworkDisconnect"> <Authorized authorizations="DockerNetworkDisconnect">
<Button <Button
dataCy={`networkDetails-disconnect${container.Name}`} data-cy={`networkDetails-disconnect${container.Name}`}
size="xsmall" size="xsmall"
color="danger" color="danger"
onClick={() => { onClick={() => {

View File

@ -41,7 +41,7 @@ export function NetworkDetailsTable({
{allowRemoveNetwork && ( {allowRemoveNetwork && (
<Authorized authorizations="DockerNetworkDelete"> <Authorized authorizations="DockerNetworkDelete">
<Button <Button
dataCy="networkDetails-deleteNetwork" data-cy="networkDetails-deleteNetwork"
size="xsmall" size="xsmall"
color="danger" color="danger"
onClick={() => onRemoveNetworkClicked()} onClick={() => onRemoveNetworkClicked()}

View File

@ -0,0 +1,76 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/portainer/environments/types';
import axios, { parseAxiosError } from '@/portainer/services/axios';
export interface VersionResponse {
ApiVersion: string;
}
export async function getVersion(environmentId: EnvironmentId) {
try {
const { data } = await axios.get<VersionResponse>(
buildUrl(environmentId, 'version')
);
return data;
} catch (err) {
throw parseAxiosError(err as Error, 'Unable to retrieve version');
}
}
export interface InfoResponse {
Swarm?: {
NodeID: string;
ControlAvailable: boolean;
};
}
export async function getInfo(environmentId: EnvironmentId) {
try {
const { data } = await axios.get<InfoResponse>(
buildUrl(environmentId, 'info')
);
return data;
} catch (err) {
throw parseAxiosError(err as Error, 'Unable to retrieve version');
}
}
function buildUrl(
environmentId: EnvironmentId,
action: string,
subAction = ''
) {
let url = `/endpoints/${environmentId}/docker/${action}`;
if (subAction) {
url += `/${subAction}`;
}
return url;
}
export function useInfo<TSelect = InfoResponse>(
environmentId: EnvironmentId,
select?: (info: InfoResponse) => TSelect
) {
return useQuery(
['environment', environmentId, 'docker', 'info'],
() => getInfo(environmentId),
{
select,
}
);
}
export function useVersion<TSelect = VersionResponse>(
environmentId: EnvironmentId,
select?: (info: VersionResponse) => TSelect
) {
return useQuery(
['environment', environmentId, 'docker', 'version'],
() => getVersion(environmentId),
{
select,
}
);
}

View File

@ -13,6 +13,7 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
'HttpRequestHelper', 'HttpRequestHelper',
'LocalStorage', 'LocalStorage',
'CONSOLE_COMMANDS_LABEL_PREFIX', 'CONSOLE_COMMANDS_LABEL_PREFIX',
'SidebarService',
function ( function (
$scope, $scope,
$state, $state,
@ -24,7 +25,8 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
ExecService, ExecService,
HttpRequestHelper, HttpRequestHelper,
LocalStorage, LocalStorage,
CONSOLE_COMMANDS_LABEL_PREFIX CONSOLE_COMMANDS_LABEL_PREFIX,
SidebarService
) { ) {
var socket, term; var socket, term;
@ -189,7 +191,7 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
$scope.$apply(); $scope.$apply();
}; };
$scope.$watch('toggle', function () { $scope.$watch(SidebarService.isSidebarOpen, function () {
setTimeout(resizefun, 400); setTimeout(resizefun, 400);
}); });

5
app/global.d.ts vendored
View File

@ -20,5 +20,8 @@ declare module 'axios-progress-bar' {
} }
interface Window { interface Window {
ddExtension: boolean; /**
* will be true if portainer is run as a Docker Desktop Extension
*/
ddExtension?: boolean;
} }

View File

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" ng-app="<%= name %>" ng-strict-di> <html lang="en" ng-app="<%= name %>" ng-strict-di data-edition="<%= process.env.PORTAINER_EDITION %>">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Portainer</title> <title>Portainer</title>
@ -39,7 +39,7 @@
<div <div
id="page-wrapper" id="page-wrapper"
ng-class="{ ng-class="{
open: toggle && ['portainer.auth', 'portainer.init.admin', 'portainer.init.endpoint'].indexOf($state.current.name) === -1, open: isSidebarOpen() && ['portainer.auth', 'portainer.init.admin', 'portainer.init.endpoint'].indexOf($state.current.name) === -1,
nopadding: ['portainer.auth', 'portainer.init.admin', 'portainer.init.endpoint', 'portainer.logout'].indexOf($state.current.name) > -1 || applicationState.loading nopadding: ['portainer.auth', 'portainer.init.admin', 'portainer.init.endpoint', 'portainer.logout'].indexOf($state.current.name) > -1 || applicationState.loading
}" }"
ng-cloak ng-cloak

View File

@ -1,75 +0,0 @@
.shell-container {
display: flex;
justify-content: space-between;
color: #424242;
background: rgb(245, 245, 245);
border-top: 1px solid rgb(190, 190, 190);
}
.shell-item {
width: 150px;
font-weight: bold;
text-align: center;
font-size: 14px;
}
.shell-item-center {
flex-grow: 1;
}
ul.sidebar li .shell-item-center a {
border-left: 1px solid rgb(143, 143, 143);
padding: 0px 5px;
color: #424242;
}
ul.sidebar li .shell-item-center a:hover {
color: rgb(51, 122, 183);
text-decoration: underline;
}
.shell-item-center a {
font-size: 12px;
}
.shell-item-right {
height: 16px;
width: 180px;
text-align: right;
padding-right: 10px;
}
.shell-item-right i {
padding: 0px 5px;
cursor: pointer;
color: rgb(89, 89, 89);
font-size: 14px;
}
.shell-item-right i:hover {
color: rgb(51, 122, 183);
}
.shell-item + .shell-item {
margin-left: 20%;
}
.normal-kubectl-shell {
position: fixed;
background: #000;
bottom: 0;
left: 0;
width: 100vw;
height: 495px;
z-index: 1000;
}
.mini-kubectl-shell {
position: fixed;
background: #000;
bottom: 0;
left: 0;
width: 100vw;
height: 35px;
z-index: 1000;
}

View File

@ -1,36 +0,0 @@
<button
type="button"
class="btn btn-xs btn-primary"
ng-click="$ctrl.connectConsole()"
ng-disabled="$ctrl.state.shell.connected"
data-cy="k8sSidebar-shellButton"
analytics-on
analytics-category="kubernetes"
analytics-event="kubernetes-kubectl-shell"
>
<i class="fa fa-terminal space-right"></i> kubectl shell
</button>
<div ng-if="$ctrl.state.shell.connected" class="{{ $ctrl.state.css }}-kubectl-shell">
<div class="shell-container">
<div class="shell-item"><i class="fas fa-terminal" style="margin-right: 5px"></i>kubectl shell</div>
<div class="shell-item-right">
<i class="fas fa-redo-alt" ng-click="$ctrl.screenClear();" data-cy="k8sShell-refreshButton"></i>
<i
class="fas {{ $ctrl.state.icon }}"
ng-click="$ctrl.miniRestore();"
data-cy="{{ $ctrl.state.icon === '.fa-window-minimize' ? 'k8sShell-restore' : 'k8sShell-minimise' }}"
></i>
<i class="fas fa-times" ng-click="$ctrl.disconnect()" data-cy="k8sShell-closeButton"></i>
</div>
</div>
<div>
<div class="nopadding">
<div>
<div id="terminal-container" class="terminal-container">
<div style="position: fixed; color: #fff">Loading Terminal...</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,8 +0,0 @@
import angular from 'angular';
import controller from './kubectl-shell.controller';
import './kubectl-shell.css';
angular.module('portainer.kubernetes').component('kubectlShell', {
templateUrl: './kubectl-shell.html',
controller,
});

View File

@ -1,10 +0,0 @@
import angular from 'angular';
angular.module('portainer.kubernetes').component('kubernetesSidebar', {
templateUrl: './kubernetes-sidebar.html',
bindings: {
endpointId: '<',
isSidebarOpen: '<',
adminAccess: '<',
},
});

View File

@ -1,120 +0,0 @@
<sidebar-menu-item
path="kubernetes.dashboard"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-tachometer-alt fa-fw"
class-name="sidebar-list"
data-cy="k8sSidebar-dashboard"
title="Dashboard"
>
Dashboard
</sidebar-menu-item>
<sidebar-menu-item
path="kubernetes.templates.custom"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-rocket fa-fw"
class-name="sidebar-list"
data-cy="k8sSidebar-customTemplates"
title="Custom Templates"
>
Custom Templates
</sidebar-menu-item>
<sidebar-menu-item
path="kubernetes.resourcePools"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-layer-group fa-fw"
class-name="sidebar-list"
data-cy="k8sSidebar-namespaces"
title="Namespaces"
>
Namespaces
</sidebar-menu-item>
<sidebar-menu-item
path="kubernetes.templates.helm"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-dharmachakra fa-fw"
class-name="sidebar-list"
data-cy="k8sSidebar-helm"
title="Helm"
>
Helm
</sidebar-menu-item>
<sidebar-menu-item
path="kubernetes.applications"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-laptop-code fa-fw"
class-name="sidebar-list"
data-cy="k8sSidebar-applications"
title="Applications"
>
Applications
</sidebar-menu-item>
<sidebar-menu-item
path="kubernetes.configurations"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-file-code fa-fw"
class-name="sidebar-list"
data-cy="k8sSidebar-configurations"
title="ConfigMaps & Secrets"
>
ConfigMaps & Secrets
</sidebar-menu-item>
<sidebar-menu-item
path="kubernetes.volumes"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-database fa-fw"
class-name="sidebar-list"
data-cy="k8sSidebar-volumes"
title="Volumes"
>
Volumes
</sidebar-menu-item>
<sidebar-menu
icon-class="fa-server fa-fw"
label="Cluster"
path="kubernetes.cluster"
path-params="{ endpointId: $ctrl.endpointId }"
is-sidebar-open="$ctrl.isSidebarOpen"
children-paths="['kubernetes.cluster', 'portainer.k8sendpoint.kubernetesConfig', 'portainer.k8sendpoint.securityConstraint', 'kubernetes.registries', 'kubernetes.registries.access']"
data-cy="k8sSidebar-cluster"
>
<sidebar-menu-item
ng-if="$ctrl.adminAccess"
authorization="K8sClusterSetupRW"
path="portainer.k8sendpoint.kubernetesConfig"
path-params="{ id: $ctrl.endpointId }"
class-name="sidebar-sublist"
data-cy="k8sSidebar-setup"
title="Setup"
>
Setup
</sidebar-menu-item>
<sidebar-menu-item
authorization="K8sClusterSetupRW"
path="portainer.k8sendpoint.securityConstraint"
path-params="{ id: $ctrl.endpointId }"
class-name="sidebar-sublist"
data-cy="k8sSidebar-security-constraints"
title="Security constraints"
>
Security constraints
</sidebar-menu-item>
<sidebar-menu-item
authorization="PortainerRegistryList"
path="kubernetes.registries"
path-params="{ endpointId: $ctrl.endpointId }"
class-name="sidebar-sublist"
data-cy="k8sSidebar-registries"
title="Registries"
>
Registries
</sidebar-menu-item>
</sidebar-menu>

View File

@ -2,7 +2,6 @@ import { saveAs } from 'file-saver';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/portainer/environments/types'; import { EnvironmentId } from '@/portainer/environments/types';
import { publicSettings } from '@/portainer/settings/settings.service';
const baseUrl = 'kubernetes'; const baseUrl = 'kubernetes';
@ -22,22 +21,3 @@ export async function downloadKubeconfigFile(environmentIds: EnvironmentId[]) {
throw parseAxiosError(e as Error, ''); throw parseAxiosError(e as Error, '');
} }
} }
export async function expiryMessage() {
const settings = await publicSettings();
const prefix = 'Kubeconfig file will';
switch (settings.KubeconfigExpiry) {
case '24h':
return `${prefix} expire in 1 day.`;
case '168h':
return `${prefix} expire in 7 days.`;
case '720h':
return `${prefix} expire in 30 days.`;
case '8640h':
return `${prefix} expire in 1 year.`;
case '0':
default:
return `${prefix} not expire.`;
}
}

View File

@ -10,6 +10,7 @@ import teamsModule from './teams';
import homeModule from './home'; import homeModule from './home';
import { accessControlModule } from './access-control'; import { accessControlModule } from './access-control';
import { reactModule } from './react'; import { reactModule } from './react';
import { sidebarModule } from './react/views/sidebar';
async function initAuthentication(authManager, Authentication, $rootScope, $state) { async function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh(); authManager.checkAuthOnRefresh();
@ -40,6 +41,7 @@ angular
teamsModule, teamsModule,
accessControlModule, accessControlModule,
reactModule, reactModule,
sidebarModule,
]) ])
.config([ .config([
'$stateRegistryProvider', '$stateRegistryProvider',
@ -68,8 +70,7 @@ angular
}, },
views: { views: {
'sidebar@': { 'sidebar@': {
templateUrl: './views/sidebar/sidebar.html', component: 'sidebar',
controller: 'SidebarController',
}, },
}, },
}; };

View File

@ -106,9 +106,9 @@ export function AccessControlPanel({
function useRestrictions(resourceControl?: ResourceControlViewModel) { function useRestrictions(resourceControl?: ResourceControlViewModel) {
const { user, isAdmin } = useUser(); const { user, isAdmin } = useUser();
const memberships = useUserMembership(user?.Id); const memberships = useUserMembership(user.Id);
if (!resourceControl || isAdmin || !user) { if (!resourceControl || isAdmin) {
return { return {
isPartOfRestrictedUsers: false, isPartOfRestrictedUsers: false,
isLeaderOfAnyRestrictedTeams: false, isLeaderOfAnyRestrictedTeams: false,

View File

@ -1,7 +1,6 @@
import angular from 'angular'; import angular from 'angular';
import formComponentsModule from './form-components'; import formComponentsModule from './form-components';
import sidebarModule from './sidebar';
import gitFormModule from './forms/git-form'; import gitFormModule from './forms/git-form';
import porAccessManagementModule from './accessManagement'; import porAccessManagementModule from './accessManagement';
import widgetModule from './widget'; import widgetModule from './widget';
@ -13,7 +12,7 @@ import { beFeatureIndicator } from './BEFeatureIndicator';
import { InformationPanelAngular } from './InformationPanel'; import { InformationPanelAngular } from './InformationPanel';
export default angular export default angular
.module('portainer.app.components', [pageHeaderModule, boxSelectorModule, widgetModule, sidebarModule, gitFormModule, porAccessManagementModule, formComponentsModule]) .module('portainer.app.components', [pageHeaderModule, boxSelectorModule, widgetModule, gitFormModule, porAccessManagementModule, formComponentsModule])
.component('informationPanel', InformationPanelAngular) .component('informationPanel', InformationPanelAngular)
.component('portainerTooltip', TooltipAngular) .component('portainerTooltip', TooltipAngular)

View File

@ -1,4 +0,0 @@
.sidebar-menu-item > a {
display: flex;
justify-content: space-between;
}

View File

@ -1,49 +0,0 @@
import { Meta, Story } from '@storybook/react';
import { SidebarMenuItem } from './SidebarMenuItem';
const meta: Meta = {
title: 'Components/SidebarMenuItem',
component: SidebarMenuItem,
};
export default meta;
interface StoryProps {
iconClass?: string;
className: string;
itemName: string;
linkName: string;
}
function Template({ iconClass, className, itemName, linkName }: StoryProps) {
return (
<ul className="sidebar">
<div className="sidebar-list">
<SidebarMenuItem
path="example.path"
pathParams={{ endpointId: 1 }}
iconClass={iconClass}
className={className}
itemName={itemName}
>
{linkName}
</SidebarMenuItem>
</div>
</ul>
);
}
export const Primary: Story<StoryProps> = Template.bind({});
Primary.args = {
iconClass: 'fa-tachometer-alt fa-fw',
className: 'exampleItemClass',
itemName: 'ExampleItem',
linkName: 'Item with icon',
};
export const WithoutIcon: Story<StoryProps> = Template.bind({});
WithoutIcon.args = {
className: 'exampleItemClass',
itemName: 'ExampleItem',
linkName: 'Item without icon',
};

View File

@ -1,51 +0,0 @@
import { render } from '@/react-tools/test-utils';
import { SidebarMenuItem } from './SidebarMenuItem';
test('should be visible & have expected class', () => {
const { getByLabelText } = renderComponent('testClass');
const listItem = getByLabelText('sidebarItem');
expect(listItem).toBeVisible();
expect(listItem).toHaveClass('testClass');
});
test('icon should with correct icon if iconClass is provided', () => {
const { getByLabelText } = renderComponent('', 'testIconClass');
const sidebarIcon = getByLabelText('itemIcon');
expect(sidebarIcon).toBeVisible();
expect(sidebarIcon).toHaveClass('testIconClass');
});
test('icon should not be rendered if iconClass is not provided', () => {
const { queryByLabelText } = renderComponent();
expect(queryByLabelText('itemIcon')).not.toBeInTheDocument();
});
test('should render children', () => {
const { getByLabelText } = renderComponent('', '', 'Test');
expect(getByLabelText('sidebarItem')).toHaveTextContent('Test');
});
test('li element should have correct accessibility label', () => {
const { queryByLabelText } = renderComponent('', '', '', 'testItemLabel');
expect(queryByLabelText('testItemLabel')).toBeInTheDocument();
});
function renderComponent(
className = '',
iconClass = '',
linkText = '',
itemName = 'sidebarItem'
) {
return render(
<SidebarMenuItem
path=""
pathParams={{ endpointId: 1 }}
iconClass={iconClass}
className={className}
itemName={itemName}
>
{linkText}
</SidebarMenuItem>
);
}

View File

@ -1,44 +0,0 @@
import { PropsWithChildren } from 'react';
import clsx from 'clsx';
import { UISrefActive } from '@uirouter/react';
import { Link } from '@@/Link';
import '../sidebar.css';
import styles from './SidebarMenuItem.module.css';
interface Props {
path: string;
pathParams: object;
iconClass?: string;
className: string;
itemName: string;
}
export function SidebarMenuItem({
path,
pathParams,
iconClass,
className,
itemName,
children,
}: PropsWithChildren<Props>) {
return (
<li
className={clsx('sidebar-menu-item', styles.sidebarMenuItem, className)}
aria-label={itemName}
>
<UISrefActive class="active">
<Link to={path} params={pathParams} title={itemName}>
{children}
{iconClass && (
<i
className={clsx('menu-icon fa', iconClass)}
aria-label="itemIcon"
/>
)}
</Link>
</UISrefActive>
</li>
);
}

View File

@ -1 +0,0 @@
export { SidebarMenuItem } from './SidebarMenuItem';

View File

@ -1,12 +0,0 @@
import angular from 'angular';
import './sidebar.css';
import { sidebarMenu } from './sidebar-menu';
import { sidebarSection } from './sidebar-section';
import { sidebarMenuItem } from './sidebar-menu-item';
export default angular
.module('portainer.app.components.sidebar', [])
.component('sidebarMenu', sidebarMenu)
.component('sidebarMenuItem', sidebarMenuItem)
.component('sidebarSection', sidebarSection).name;

View File

@ -1,12 +0,0 @@
import './sidebar-menu-item.css';
export const sidebarMenuItem = {
templateUrl: './sidebar-menu-item.html',
bindings: {
path: '@',
pathParams: '<',
iconClass: '@',
className: '@',
},
transclude: true,
};

View File

@ -1,4 +0,0 @@
.sidebar-menu-item > a {
display: flex;
justify-content: space-between;
}

View File

@ -1,6 +0,0 @@
<li class="sidebar-menu-item" ng-class="$ctrl.className">
<a ui-state="$ctrl.path" ui-state-params="$ctrl.pathParams" ui-sref-active="active">
<ng-transclude></ng-transclude>
<span ng-if="$ctrl.iconClass" class="menu-icon fa" ng-class="$ctrl.iconClass"></span>
</a>
</li>

View File

@ -1,18 +0,0 @@
import './sidebar-menu.css';
import controller from './sidebar-menu.controller.js';
export const sidebarMenu = {
templateUrl: './sidebar-menu.html',
controller,
bindings: {
iconClass: '@',
path: '@', // string
pathParams: '<', //object, corresponds to https://ui-router.github.io/ng1/docs/latest/modules/directives.html#uistatedirective
childrenPaths: '<', // []string (globs)
label: '@', // string
isSidebarOpen: '<',
currentState: '@',
},
transclude: true,
};

View File

@ -1,45 +0,0 @@
class SidebarMenuController {
/* @ngInject */
constructor($state) {
this.$state = $state;
this.state = {
forceOpen: false,
};
}
isOpen() {
if (!this.isSidebarOpen) {
return false;
}
if (this.state.forceOpen) {
return true;
}
return this.isOpenByPathState();
}
isOpenByPathState() {
const currentName = this.$state.current.name;
return currentName.startsWith(this.path) || this.childrenPaths.includes(currentName);
}
onClickArrow(event) {
event.stopPropagation();
event.preventDefault();
// prevent toggling when menu is open by state
if (this.isOpenByPathState()) {
return;
}
this.state.forceOpen = !this.state.forceOpen;
}
$onInit() {
this.childrenPaths = this.childrenPaths || [];
}
}
export default SidebarMenuController;

View File

@ -1,15 +0,0 @@
.sidebar-menu .sidebar-menu-head {
position: relative;
}
.sidebar-menu .sidebar-menu-head .sidebar-menu-indicator {
background: none;
border: 0;
width: fit-content;
height: fit-content;
color: white;
margin: 0 10px;
padding: 0;
position: absolute;
left: -5px;
}

View File

@ -1,20 +0,0 @@
<li class="sidebar-list sidebar-menu">
<sidebar-menu-item
path="{{::$ctrl.path }}"
path-params="$ctrl.pathParams"
icon-class="{{::$ctrl.iconClass}}"
data-cy="portainerSidebar-{{ ::$ctrl.label }}"
title="{{ ::$ctrl.label }}"
>
<div class="sidebar-menu-head">
<button ng-click="$ctrl.onClickArrow($event)" class="small sidebar-menu-indicator">
<i class="fas" ng-class="$ctrl.isOpen() ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
</button>
{{ ::$ctrl.label }}
</div>
</sidebar-menu-item>
<div class="sidebar-list-items" ng-if="$ctrl.isOpen()">
<ng-transclude></ng-transclude>
</div>
</li>

View File

@ -1,7 +0,0 @@
export const sidebarSection = {
templateUrl: './sidebar-section.html',
transclude: true,
bindings: {
title: '@',
},
};

View File

@ -1,7 +0,0 @@
<div class="sidebar-section">
<li class="sidebar-title">
<span>{{ ::$ctrl.title }}</span>
</li>
<div class="sidebar-section-items" ng-transclude> </div>
</div>

View File

@ -1,327 +0,0 @@
#page-wrapper.open #sidebar-wrapper {
left: 150px;
}
/* #592727 RED */
/* #2f5927 GREEN */
/* #30426a BLUE (default)*/
/* Sidebar background color */
/* Sidebar header and footer color */
/* Sidebar title text colour */
/* Sidebar menu item hover color */
/**
* Sidebar
*/
#sidebar-wrapper {
background: var(--bg-sidebar-wrapper-color);
}
ul.sidebar .sidebar-main a,
.sidebar-footer,
ul.sidebar .sidebar-list a:hover,
#page-wrapper:not(.open) ul.sidebar .sidebar-title.separator {
/* Sidebar header and footer color */
background: var(--hover-sidebar-color);
}
ul.sidebar {
position: absolute;
top: 0;
bottom: 0;
padding: 0;
margin: 0;
list-style: none;
text-indent: 20px;
overflow-x: hidden;
overflow-y: auto;
}
ul.sidebar li a {
color: #fff;
text-decoration: none;
width: 250px;
}
ul.sidebar .sidebar-main {
height: 65px;
}
ul.sidebar .sidebar-main a {
font-size: 18px;
line-height: 60px;
}
ul.sidebar .sidebar-main a:hover {
cursor: pointer;
}
ul.sidebar .sidebar-main .menu-icon {
float: right;
font-size: 18px;
padding-right: 28px;
line-height: 60px;
}
ul.sidebar .sidebar-title {
color: var(--text-sidebar-title-color);
font-size: 12px;
height: 35px;
line-height: 40px;
text-transform: uppercase;
transition: all 0.6s ease 0s;
}
ul.sidebar .sidebar-list a {
text-indent: 25px;
font-size: 15px;
color: var(--text-sidebar-list-color);
line-height: 40px;
padding-left: 5px;
border-left: 3px solid transparent;
}
ul.sidebar .sidebar-list a:hover {
color: #fff;
border-left-color: #e99d1a;
}
ul.sidebar .sidebar-list a:hover .menu-icon {
text-indent: 25px;
}
ul.sidebar .sidebar-list .menu-icon {
padding-right: 30px;
line-height: 40px;
width: 70px;
}
#page-wrapper:not(.open) ul.sidebar {
bottom: 0;
}
#page-wrapper:not(.open) ul.sidebar .sidebar-title {
display: none;
height: 0px;
text-indent: -100px;
}
#page-wrapper:not(.open) ul.sidebar .sidebar-title.separator {
display: block;
height: 2px;
margin: 13px 0;
}
#page-wrapper:not(.open) ul.sidebar .sidebar-list a:hover span {
border-left: 3px solid #e99d1a;
text-indent: 22px;
}
#page-wrapper:not(.open) .sidebar-footer {
display: none;
}
.sidebar-footer {
position: absolute;
height: 40px;
bottom: 0;
width: 100%;
padding: 0;
margin: 0;
transition: all 0.6s ease 0s;
text-align: center;
}
.sidebar-footer div a {
color: var(--text-sidebar-list-color);
font-size: 12px;
line-height: 43px;
}
.sidebar-footer div a:hover {
color: #ffffff;
text-decoration: none;
}
/* #592727 RED */
/* #2f5927 GREEN */
/* #30426a BLUE (default)*/
/* Sidebar background color */
/* Sidebar header and footer color */
/* Sidebar title text colour */
/* Sidebar menu item hover color */
ul.sidebar {
position: relative;
overflow: hidden;
flex-shrink: 0;
}
ul.sidebar .sidebar-title {
height: auto;
}
ul.sidebar .sidebar-title.endpoint-name {
color: #fff;
text-align: center;
text-indent: 0;
}
ul.sidebar .sidebar-list a {
font-size: 14px;
}
ul.sidebar .sidebar-list a.active {
color: #fff;
border-left-color: var(--border-sidebar-color);
background: var(--hover-sidebar-color);
}
.sidebar-header {
height: 60px;
list-style: none;
text-indent: 20px;
font-size: 18px;
background: var(--bg-sidebar-header-color);
}
.sidebar-header a {
color: #fff;
}
.sidebar-header a:hover {
text-decoration: none;
}
.sidebar-header .menu-icon {
float: right;
padding-right: 28px;
line-height: 60px;
}
#page-wrapper:not(.open) .sidebar-footer-content {
display: none;
}
.sidebar-footer-content {
text-align: center;
}
.sidebar-footer-content .logo {
width: 100%;
max-width: 100px;
height: 100%;
max-height: 35px;
margin: 2px 0 2px 20px;
}
.sidebar-footer-content .update-notification {
font-size: 14px;
padding: 12px;
border-radius: 2px;
background-color: #ff851b;
margin-bottom: 5px;
}
.sidebar-footer-content .version {
font-size: 11px;
margin: 11px 20px 0 7px;
color: #fff;
}
.sidebar-footer-content .edition-version {
font-size: 10px;
margin-bottom: 8px;
color: #fff;
}
#sidebar-wrapper {
display: flex;
flex-flow: column;
}
.sidebar-content {
display: flex;
flex-direction: column;
justify-content: space-between;
overflow-y: auto;
overflow-x: hidden;
height: 100%;
}
ul.sidebar .sidebar-list a.active .menu-icon {
text-indent: 25px;
}
ul.sidebar .sidebar-list .sidebar-sublist a {
text-indent: 35px;
font-size: 12px;
color: var(--text-sidebar-list-color);
line-height: 36px;
}
ul.sidebar .sidebar-title {
line-height: 36px;
}
ul.sidebar .sidebar-title .form-control {
height: 36px;
padding: 6px 12px;
}
ul.sidebar .sidebar-list a,
ul.sidebar .sidebar-list .sidebar-sublist a {
line-height: 36px;
letter-spacing: -0.03em;
}
ul.sidebar .sidebar-list .menu-icon {
line-height: 36px;
}
ul.sidebar .sidebar-list .sidebar-sublist a.active {
color: #fff;
border-left: 3px solid #fff;
background: var(--bg-sidebar-color);
}
@media (max-height: 785px) {
ul.sidebar .sidebar-title {
line-height: 26px;
}
ul.sidebar .sidebar-title .form-control {
height: 26px;
padding: 3px 6px;
}
ul.sidebar .sidebar-list a,
ul.sidebar .sidebar-list .sidebar-sublist a {
font-size: 12px;
line-height: 26px;
}
ul.sidebar .sidebar-list .menu-icon {
line-height: 26px;
}
}
@media (min-height: 786px) and (max-height: 924px) {
ul.sidebar .sidebar-title {
line-height: 30px;
}
ul.sidebar .sidebar-title .form-control {
height: 30px;
padding: 5px 10px;
}
ul.sidebar .sidebar-list a,
ul.sidebar .sidebar-list .sidebar-sublist a {
font-size: 12px;
line-height: 30px;
}
ul.sidebar .sidebar-list .menu-icon {
line-height: 30px;
}
}

View File

@ -8,7 +8,7 @@ import type {
Environment, Environment,
EnvironmentId, EnvironmentId,
EnvironmentType, EnvironmentType,
EnvironmentSettings, EnvironmentSecuritySettings,
EnvironmentStatus, EnvironmentStatus,
} from '../types'; } from '../types';
@ -239,7 +239,7 @@ export async function forceUpdateService(
export async function updateSettings( export async function updateSettings(
id: EnvironmentId, id: EnvironmentId,
settings: EnvironmentSettings settings: EnvironmentSecuritySettings
) { ) {
try { try {
await axios.put(buildUrl(id, 'settings'), settings); await axios.put(buildUrl(id, 'settings'), settings);
@ -247,14 +247,3 @@ export async function updateSettings(
throw parseAxiosError(e as Error); throw parseAxiosError(e as Error);
} }
} }
export async function trustEndpoint(id: EnvironmentId) {
try {
const { data: endpoint } = await axios.put<Environment>(buildUrl(id), {
UserTrusted: true,
});
return endpoint;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to update environment');
}
}

View File

@ -0,0 +1,13 @@
import { useQuery } from 'react-query';
import { getEndpoint } from '@/portainer/environments/environment.service';
import { EnvironmentId } from '@/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
export function useEnvironment(id?: EnvironmentId) {
return useQuery(['environments', id], () => (id ? getEndpoint(id) : null), {
...withError('Failed loading environment'),
staleTime: 50,
enabled: !!id,
});
}

View File

@ -61,6 +61,27 @@ export type EnvironmentEdge = {
CommandInterval: number; CommandInterval: number;
}; };
export interface EnvironmentSecuritySettings {
// Whether non-administrator should be able to use bind mounts when creating containers
allowBindMountsForRegularUsers: boolean;
// Whether non-administrator should be able to use privileged mode when creating containers
allowPrivilegedModeForRegularUsers: boolean;
// Whether non-administrator should be able to browse volumes
allowVolumeBrowserForRegularUsers: boolean;
// Whether non-administrator should be able to use the host pid
allowHostNamespaceForRegularUsers: boolean;
// Whether non-administrator should be able to use device mapping
allowDeviceMappingForRegularUsers: boolean;
// Whether non-administrator should be able to manage stacks
allowStackManagementForRegularUsers: boolean;
// Whether non-administrator should be able to use container capabilities
allowContainerCapabilitiesForRegularUsers: boolean;
// Whether non-administrator should be able to use sysctl settings
allowSysctlSettingForRegularUsers: boolean;
// Whether host management features are enabled
enableHostManagementFeatures: boolean;
}
export type Environment = { export type Environment = {
Id: EnvironmentId; Id: EnvironmentId;
Type: EnvironmentType; Type: EnvironmentType;
@ -81,6 +102,7 @@ export type Environment = {
UserTrusted: boolean; UserTrusted: boolean;
AMTDeviceGUID?: string; AMTDeviceGUID?: string;
Edge: EnvironmentEdge; Edge: EnvironmentEdge;
SecuritySettings: EnvironmentSecuritySettings;
}; };
/** /**
* TS reference of endpoint_create.go#EndpointCreationType iota * TS reference of endpoint_create.go#EndpointCreationType iota
@ -99,24 +121,3 @@ export enum PlatformType {
Kubernetes, Kubernetes,
Azure, Azure,
} }
export interface EnvironmentSettings {
// Whether non-administrator should be able to use bind mounts when creating containers
allowBindMountsForRegularUsers: boolean;
// Whether non-administrator should be able to use privileged mode when creating containers
allowPrivilegedModeForRegularUsers: boolean;
// Whether non-administrator should be able to browse volumes
allowVolumeBrowserForRegularUsers: boolean;
// Whether non-administrator should be able to use the host pid
allowHostNamespaceForRegularUsers: boolean;
// Whether non-administrator should be able to use device mapping
allowDeviceMappingForRegularUsers: boolean;
// Whether non-administrator should be able to manage stacks
allowStackManagementForRegularUsers: boolean;
// Whether non-administrator should be able to use container capabilities
allowContainerCapabilitiesForRegularUsers: boolean;
// Whether non-administrator should be able to use sysctl settings
allowSysctlSettingForRegularUsers: boolean;
// Whether host management features are enabled
enableHostManagementFeatures: boolean;
}

View File

@ -13,7 +13,7 @@ export function getPlatformType(envType: EnvironmentType) {
case EnvironmentType.Azure: case EnvironmentType.Azure:
return PlatformType.Azure; return PlatformType.Azure;
default: default:
throw new Error(`Environment Type ${envType} is not supported`); throw new Error(`${envType} is not a supported environment type`);
} }
} }

View File

@ -10,14 +10,11 @@ import { Button } from '@@/buttons';
import { KubeconfigPrompt } from './KubeconfigPrompt'; import { KubeconfigPrompt } from './KubeconfigPrompt';
import '@reach/dialog/styles.css'; import '@reach/dialog/styles.css';
export interface KubeconfigButtonProps { export interface Props {
environments: Environment[]; environments: Environment[];
envQueryParams: EnvironmentsQueryParams; envQueryParams: EnvironmentsQueryParams;
} }
export function KubeconfigButton({ export function KubeconfigButton({ environments, envQueryParams }: Props) {
environments,
envQueryParams,
}: KubeconfigButtonProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
if (!environments) { if (!environments) {

View File

@ -1,5 +1,4 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from 'react-query';
import { DialogOverlay } from '@reach/dialog'; import { DialogOverlay } from '@reach/dialog';
import * as kcService from '@/kubernetes/services/kubeconfig.service'; import * as kcService from '@/kubernetes/services/kubeconfig.service';
@ -8,6 +7,7 @@ import { EnvironmentType } from '@/portainer/environments/types';
import { EnvironmentsQueryParams } from '@/portainer/environments/environment.service/index'; import { EnvironmentsQueryParams } from '@/portainer/environments/environment.service/index';
import { usePaginationLimitState } from '@/portainer/hooks/usePaginationLimitState'; import { usePaginationLimitState } from '@/portainer/hooks/usePaginationLimitState';
import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList'; import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList';
import { usePublicSettings } from '@/portainer/settings/queries';
import { PaginationControls } from '@@/PaginationControls'; import { PaginationControls } from '@@/PaginationControls';
import { Checkbox } from '@@/form-components/Checkbox'; import { Checkbox } from '@@/form-components/Checkbox';
@ -29,10 +29,11 @@ export function KubeconfigPrompt({
}: KubeconfigPromptProps) { }: KubeconfigPromptProps) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey); const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
const kubeServiceExpiryQuery = useQuery(['kubeServiceExpiry'], async () => {
const expiryMessage = await kcService.expiryMessage(); const expiryQuery = usePublicSettings((settings) =>
return expiryMessage; expiryMessage(settings.KubeconfigExpiry)
}); );
const { selection, toggle: toggleSelection, selectionSize } = useSelection(); const { selection, toggle: toggleSelection, selectionSize } = useSelection();
const { environments, totalCount } = useEnvironmentList({ const { environments, totalCount } = useEnvironmentList({
...envQueryParams, ...envQueryParams,
@ -67,9 +68,7 @@ export function KubeconfigPrompt({
Select the kubernetes environments to add to the kubeconfig Select the kubernetes environments to add to the kubeconfig
file. You may select across multiple pages. file. You may select across multiple pages.
</span> </span>
<span className="space-left"> <span className="space-left">{expiryQuery.data}</span>
{kubeServiceExpiryQuery.data}
</span>
</div> </div>
</form> </form>
<br /> <br />
@ -140,3 +139,20 @@ export function KubeconfigPrompt({
} }
} }
} }
export function expiryMessage(expiry: string) {
const prefix = 'Kubeconfig file will';
switch (expiry) {
case '24h':
return `${prefix} expire in 1 day.`;
case '168h':
return `${prefix} expire in 7 days.`;
case '720h':
return `${prefix} expire in 30 days.`;
case '8640h':
return `${prefix} expire in 1 year.`;
case '0':
default:
return `${prefix} not expire.`;
}
}

View File

@ -0,0 +1 @@
export { KubeconfigButton } from './KubeconfigButton';

View File

@ -7,10 +7,14 @@ import {
useEffect, useEffect,
useState, useState,
useMemo, useMemo,
PropsWithChildren,
} from 'react'; } from 'react';
import { isAdmin } from '@/portainer/users/user.helpers';
import { getUser } from '../users/user.service'; import { getUser } from '../users/user.service';
import { User, UserId } from '../users/types'; import { User, UserId } from '../users/types';
import { EnvironmentId } from '../environments/types';
import { useLocalStorage } from './useLocalStorage'; import { useLocalStorage } from './useLocalStorage';
@ -27,9 +31,17 @@ export function useUser() {
throw new Error('should be nested under UserProvider'); throw new Error('should be nested under UserProvider');
} }
const { user } = context;
if (typeof user === 'undefined') {
throw new Error('should be authenticated');
}
return useMemo( return useMemo(
() => ({ user: context.user, isAdmin: isAdmin(context.user) }), () => ({
[context.user] user,
isAdmin: isAdmin(user),
}),
[user]
); );
} }
@ -37,22 +49,49 @@ export function useAuthorizations(
authorizations: string | string[], authorizations: string | string[],
adminOnlyCE = false adminOnlyCE = false
) { ) {
const authorizationsArray =
typeof authorizations === 'string' ? [authorizations] : authorizations;
const { user } = useUser(); const { user } = useUser();
const { params } = useCurrentStateAndParams(); const {
params: { endpointId },
} = useCurrentStateAndParams();
if (!user) { if (!user) {
return false; return false;
} }
return hasAuthorizations(user, authorizations, endpointId, adminOnlyCE);
}
export function isEnvironmentAdmin(
user: User,
environmentId: EnvironmentId,
adminOnlyCE = false
) {
return hasAuthorizations(
user,
['EndpointResourcesAccess'],
environmentId,
adminOnlyCE
);
}
export function hasAuthorizations(
user: User,
authorizations: string | string[],
environmentId?: EnvironmentId,
adminOnlyCE = false
) {
const authorizationsArray =
typeof authorizations === 'string' ? [authorizations] : authorizations;
if (authorizationsArray.length === 0) {
return true;
}
if (process.env.PORTAINER_EDITION === 'CE') { if (process.env.PORTAINER_EDITION === 'CE') {
return !adminOnlyCE || isAdmin(user); return !adminOnlyCE || isAdmin(user);
} }
const { endpointId } = params; if (!environmentId) {
if (!endpointId) {
return false; return false;
} }
@ -62,12 +101,12 @@ export function useAuthorizations(
if ( if (
!user.EndpointAuthorizations || !user.EndpointAuthorizations ||
!user.EndpointAuthorizations[endpointId] !user.EndpointAuthorizations[environmentId]
) { ) {
return false; return false;
} }
const userEndpointAuthorizations = user.EndpointAuthorizations[endpointId]; const userEndpointAuthorizations = user.EndpointAuthorizations[environmentId];
return authorizationsArray.some( return authorizationsArray.some(
(authorization) => userEndpointAuthorizations[authorization] (authorization) => userEndpointAuthorizations[authorization]
); );
@ -75,11 +114,15 @@ export function useAuthorizations(
interface AuthorizedProps { interface AuthorizedProps {
authorizations: string | string[]; authorizations: string | string[];
children: ReactNode; adminOnlyCE?: boolean;
} }
export function Authorized({ authorizations, children }: AuthorizedProps) { export function Authorized({
const isAllowed = useAuthorizations(authorizations); authorizations,
adminOnlyCE = false,
children,
}: PropsWithChildren<AuthorizedProps>) {
const isAllowed = useAuthorizations(authorizations, adminOnlyCE);
return isAllowed ? <>{children}</> : null; return isAllowed ? <>{children}</> : null;
} }
@ -90,7 +133,7 @@ interface UserProviderProps {
export function UserProvider({ children }: UserProviderProps) { export function UserProvider({ children }: UserProviderProps) {
const [jwt] = useLocalStorage('JWT', ''); const [jwt] = useLocalStorage('JWT', '');
const [user, setUser] = useState<User | undefined>(); const [user, setUser] = useState<User>();
useEffect(() => { useEffect(() => {
if (jwt !== '') { if (jwt !== '') {
@ -106,7 +149,7 @@ export function UserProvider({ children }: UserProviderProps) {
return null; return null;
} }
if (providerState.user === null) { if (!providerState.user) {
return null; return null;
} }
@ -122,10 +165,6 @@ export function UserProvider({ children }: UserProviderProps) {
} }
} }
function isAdmin(user?: User): boolean {
return !!user && user.Role === 1;
}
export function useIsAdmin() { export function useIsAdmin() {
const { user } = useUser(); const { user } = useUser();
return !!user && isAdmin(user); return !!user && isAdmin(user);

View File

@ -0,0 +1,10 @@
import angular from 'angular';
import { AngularSidebarService } from '@/react/sidebar/useSidebarState';
import { Sidebar } from '@/react/sidebar/Sidebar';
import { r2a } from '@/react-tools/react2angular';
export const sidebarModule = angular
.module('portainer.app.sidebar', [])
.component('sidebar', r2a(Sidebar, []))
.factory('SidebarService', AngularSidebarService).name;

View File

@ -2,7 +2,7 @@ import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '../axios'; import axios, { parseAxiosError } from '../axios';
interface NodesCountResponse { export interface NodesCountResponse {
nodes: number; nodes: number;
} }
@ -16,6 +16,7 @@ export async function getNodesCount() {
} }
export interface StatusResponse { export interface StatusResponse {
Edition: string;
Version: string; Version: string;
InstanceID: string; InstanceID: string;
} }
@ -24,6 +25,10 @@ export async function getStatus() {
try { try {
const { data } = await axios.get<StatusResponse>(buildUrl()); const { data } = await axios.get<StatusResponse>(buildUrl());
if (process.env.PORTAINER_EDITION !== 'CE') {
data.Edition = 'Business Edition';
}
return data; return data;
} catch (error) { } catch (error) {
throw parseAxiosError(error as Error); throw parseAxiosError(error as Error);
@ -36,6 +41,22 @@ export function useStatus<T = StatusResponse>(
return useQuery(['status'], () => getStatus(), { select }); return useQuery(['status'], () => getStatus(), { select });
} }
export interface VersionResponse {
// Whether portainer has an update available
UpdateAvailable: boolean;
// The latest version available
LatestVersion: string;
}
export async function getVersionStatus() {
try {
const { data } = await axios.get<VersionResponse>(buildUrl('version'));
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
function buildUrl(action?: string) { function buildUrl(action?: string) {
let url = '/status'; let url = '/status';

View File

@ -126,12 +126,6 @@ angular.module('portainer.app').factory('LocalStorage', [
const activeTab = localStorageService.get('active_tab_' + key); const activeTab = localStorageService.get('active_tab_' + key);
return activeTab === null ? 0 : activeTab; return activeTab === null ? 0 : activeTab;
}, },
storeToolbarToggle(value) {
localStorageService.set('toolbar_toggle', value);
},
getToolbarToggle() {
return localStorageService.get('toolbar_toggle');
},
storeLogoutReason: (reason) => localStorageService.set('logout_reason', reason), storeLogoutReason: (reason) => localStorageService.set('logout_reason', reason),
getLogoutReason: () => localStorageService.get('logout_reason'), getLogoutReason: () => localStorageService.get('logout_reason'),
cleanLogoutReason: () => localStorageService.remove('logout_reason'), cleanLogoutReason: () => localStorageService.remove('logout_reason'),

View File

@ -22,7 +22,6 @@ import {
confirmContainerRecreation, confirmContainerRecreation,
confirmServiceForceUpdate, confirmServiceForceUpdate,
confirmStackUpdate, confirmStackUpdate,
confirmKubeconfigSelection,
selectRegistry, selectRegistry,
} from './prompt'; } from './prompt';
@ -58,7 +57,6 @@ export function ModalServiceAngular() {
confirmStackUpdate, confirmStackUpdate,
selectRegistry, selectRegistry,
confirmContainerDeletion, confirmContainerDeletion,
confirmKubeconfigSelection,
confirmForceChangePassword, confirmForceChangePassword,
}; };
} }

View File

@ -177,30 +177,6 @@ export function confirmStackUpdate(
switchEle.prop('style', 'margin-left:20px'); switchEle.prop('style', 'margin-left:20px');
} }
export function confirmKubeconfigSelection(
options: InputOption[],
expiryMessage: string,
callback: PromptCallback
) {
const message = sanitize(
`Select the kubernetes environment(s) to add to the kubeconfig file.</br>${expiryMessage}`
);
const box = prompt({
title: 'Download kubeconfig file',
inputType: 'checkbox',
inputOptions: options,
buttons: {
confirm: {
label: 'Download file',
className: 'btn-primary',
},
},
callback,
});
customizeCheckboxPrompt(box, message, true, true);
}
function customizeCheckboxPrompt( function customizeCheckboxPrompt(
box: JQuery<HTMLElement>, box: JQuery<HTMLElement>,
message: string, message: string,

View File

@ -29,11 +29,6 @@ function StateManagerFactory(
}, },
}; };
manager.setVersionInfo = function (versionInfo) {
state.application.versionStatus = versionInfo;
LocalStorage.storeApplicationState(state.application);
};
manager.dismissInformationPanel = function (id) { manager.dismissInformationPanel = function (id) {
state.UI.dismissedInfoPanels[id] = true; state.UI.dismissedInfoPanels[id] = true;
LocalStorage.storeUIState(state.UI); LocalStorage.storeUIState(state.UI);

View File

@ -1,26 +0,0 @@
angular.module('portainer').service('TerminalWindow', function ($window) {
const terminalHeight = 495;
this.terminalopen = function () {
const contentWrapperHeight = $window.innerHeight;
const newContentWrapperHeight = contentWrapperHeight - terminalHeight;
document.getElementById('content-wrapper').style.height = newContentWrapperHeight + 'px';
document.getElementById('content-wrapper').style.overflowY = 'auto';
document.getElementById('sidebar-wrapper').style.height = newContentWrapperHeight + 'px';
};
this.terminalclose = function () {
const wrapperCSS = {
height: '100%',
overflowY: 'initial',
};
document.getElementById('content-wrapper').style.height = wrapperCSS.height;
document.getElementById('content-wrapper').style.overflowY = wrapperCSS.overflowY;
document.getElementById('sidebar-wrapper').style.height = wrapperCSS.height;
};
this.terminalresize = function () {
const contentWrapperHeight = $window.innerHeight;
const newContentWrapperHeight = contentWrapperHeight - terminalHeight;
document.getElementById('content-wrapper').style.height = newContentWrapperHeight + 'px';
document.getElementById('content-wrapper').style.overflowY = 'auto';
document.getElementById('sidebar-wrapper').style.height = newContentWrapperHeight + 'px';
};
});

View File

@ -0,0 +1,20 @@
const terminalHeight = 495;
export function terminalClose() {
update('100%', 'initial');
}
export function terminalResize() {
const contentWrapperHeight = window.innerHeight;
const newContentWrapperHeight = contentWrapperHeight - terminalHeight;
update(`${newContentWrapperHeight}px`, 'auto');
}
function update(height: string, overflowY: string) {
const pageWrapper = document.getElementById('pageWrapper-wrapper');
if (pageWrapper) {
pageWrapper.style.height = height;
pageWrapper.style.overflowY = overflowY;
}
}

View File

@ -111,7 +111,7 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
<div className="col-sm-12"> <div className="col-sm-12">
<LoadingButton <LoadingButton
disabled={!isValid || !dirty} disabled={!isValid || !dirty}
dataCy="settings-edgeComputeButton" data-cy="settings-edgeComputeButton"
isLoading={isSubmitting} isLoading={isSubmitting}
loadingText="Saving settings..." loadingText="Saving settings..."
> >

View File

@ -145,7 +145,7 @@ export function SettingsFDO({ settings, onSubmit }: Props) {
<div className="col-sm-12"> <div className="col-sm-12">
<LoadingButton <LoadingButton
disabled={!isValid || !dirty} disabled={!isValid || !dirty}
dataCy="settings-fdoButton" data-cy="settings-fdoButton"
isLoading={isSubmitting} isLoading={isSubmitting}
loadingText="Saving settings..." loadingText="Saving settings..."
> >

View File

@ -248,7 +248,7 @@ export function SettingsOpenAMT({ settings, onSubmit }: Props) {
<div className="col-sm-12"> <div className="col-sm-12">
<LoadingButton <LoadingButton
disabled={!isValid || !dirty} disabled={!isValid || !dirty}
dataCy="settings-fdoButton" data-cy="settings-fdoButton"
isLoading={isSubmitting} isLoading={isSubmitting}
loadingText="Saving settings..." loadingText="Saving settings..."
> >

View File

@ -1,19 +1,26 @@
import { useMutation, useQuery, useQueryClient } from 'react-query'; import { useMutation, useQuery, useQueryClient } from 'react-query';
import { notifyError } from '@/portainer/services/notifications'; import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { PublicSettingsViewModel } from '../models/settings';
import { import {
publicSettings,
getSettings, getSettings,
updateSettings, updateSettings,
getPublicSettings,
} from './settings.service'; } from './settings.service';
import { Settings } from './types'; import { Settings } from './types';
export function usePublicSettings() { export function usePublicSettings<T = PublicSettingsViewModel>(
return useQuery(['settings', 'public'], () => publicSettings(), { select?: (settings: PublicSettingsViewModel) => T
onError: (err) => { ) {
notifyError('Failure', err as Error, 'Unable to retrieve settings'); return useQuery(['settings', 'public'], () => getPublicSettings(), {
}, select,
...withError('Unable to retrieve public settings'),
}); });
} }
@ -24,27 +31,18 @@ export function useSettings<T = Settings>(
return useQuery(['settings'], getSettings, { return useQuery(['settings'], getSettings, {
select, select,
enabled, enabled,
meta: { ...withError('Unable to retrieve settings'),
error: {
title: 'Failure',
message: 'Unable to retrieve settings',
},
},
}); });
} }
export function useUpdateSettingsMutation() { export function useUpdateSettingsMutation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation(updateSettings, { return useMutation(
onSuccess() { updateSettings,
return queryClient.invalidateQueries(['settings']); mutationOptions(
}, withInvalidate(queryClient, [['settings'], ['cloud']]),
meta: { withError('Unable to update settings')
error: { )
title: 'Failure', );
message: 'Unable to update settings',
},
},
});
} }

View File

@ -2,11 +2,13 @@ import { PublicSettingsViewModel } from '@/portainer/models/settings';
import axios, { parseAxiosError } from '../services/axios'; import axios, { parseAxiosError } from '../services/axios';
import { Settings } from './types'; import { PublicSettingsResponse, Settings } from './types';
export async function publicSettings() { export async function getPublicSettings() {
try { try {
const { data } = await axios.get(buildUrl('public')); const { data } = await axios.get<PublicSettingsResponse>(
buildUrl('public')
);
return new PublicSettingsViewModel(data); return new PublicSettingsViewModel(data);
} catch (e) { } catch (e) {
throw parseAxiosError( throw parseAxiosError(

View File

@ -132,3 +132,24 @@ export interface Settings {
AsyncMode: boolean; AsyncMode: boolean;
}; };
} }
export interface PublicSettingsResponse {
// URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string
LogoURL: string;
// Active authentication method for the Portainer instance. Valid values are: 1 for internal, 2 for LDAP, or 3 for oauth
AuthenticationMethod: AuthenticationMethod;
// Whether edge compute features are enabled
EnableEdgeComputeFeatures: boolean;
// Supported feature flags
Features: Record<string, boolean>;
// The URL used for oauth login
OAuthLoginURI: string;
// The URL used for oauth logout
OAuthLogoutURI: string;
// Whether portainer internal auth view will be hidden
OAuthHideInternalAuth: boolean;
// Whether telemetry is enabled
EnableTelemetry: boolean;
// The expiry of a Kubeconfig
KubeconfigExpiry: string;
}

View File

@ -92,7 +92,7 @@ export function CreateTeamForm({ users, teams, onSubmit }: Props) {
<div className="col-sm-12"> <div className="col-sm-12">
<LoadingButton <LoadingButton
disabled={!isValid} disabled={!isValid}
dataCy="team-createTeamButton" data-cy="team-createTeamButton"
isLoading={isSubmitting} isLoading={isSubmitting}
loadingText="Creating team..." loadingText="Creating team..."
> >

View File

@ -1,16 +1,37 @@
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { Role as TeamRole, TeamMembership } from '../teams/types';
import { User, UserId } from './types'; import { User, UserId } from './types';
import { isAdmin } from './user.helpers';
import { getUserMemberships, getUsers } from './user.service'; import { getUserMemberships, getUsers } from './user.service';
export function useUserMembership(userId?: UserId) { interface UseUserMembershipOptions<TSelect> {
select?(userMemberships: TeamMembership[]): TSelect;
enabled?: boolean;
}
export function useUserMembership<TSelect = TeamMembership[]>(
userId: UserId,
{ enabled, select }: UseUserMembershipOptions<TSelect> = {}
) {
return useQuery( return useQuery(
['users', userId, 'memberships'], ['users', userId, 'memberships'],
() => getUserMemberships(userId), () => getUserMemberships(userId),
{ enabled: !!userId } { select, enabled }
); );
} }
export function useIsTeamLeader(user: User) {
const query = useUserMembership(user.Id, {
enabled: !isAdmin(user),
select: (memberships) =>
memberships.some((membership) => membership.Role === TeamRole.TeamLeader),
});
return isAdmin(user) ? true : query.data;
}
export function useUsers<T = User[]>( export function useUsers<T = User[]>(
includeAdministrator: boolean, includeAdministrator: boolean,
enabled = true, enabled = true,

View File

@ -3,3 +3,7 @@ import { Role, User } from './types';
export function filterNonAdministratorUsers(users: User[]) { export function filterNonAdministratorUsers(users: User[]) {
return users.filter((user) => user.Role !== Role.Admin); return users.filter((user) => user.Role !== Role.Admin);
} }
export function isAdmin(user?: User): boolean {
return !!user && user.Role === 1;
}

View File

@ -25,12 +25,8 @@ export async function getUser(id: UserId) {
} }
} }
export async function getUserMemberships(id?: UserId) { export async function getUserMemberships(id: UserId) {
try { try {
if (!id) {
throw new Error('missing id');
}
const { data } = await axios.get<TeamMembership[]>( const { data } = await axios.get<TeamMembership[]>(
buildUrl(id, 'memberships') buildUrl(id, 'memberships')
); );

View File

@ -50,7 +50,6 @@ class AuthenticationController {
}; };
this.checkForEndpointsAsync = this.checkForEndpointsAsync.bind(this); this.checkForEndpointsAsync = this.checkForEndpointsAsync.bind(this);
this.checkForLatestVersionAsync = this.checkForLatestVersionAsync.bind(this);
this.postLoginSteps = this.postLoginSteps.bind(this); this.postLoginSteps = this.postLoginSteps.bind(this);
this.oAuthLoginAsync = this.oAuthLoginAsync.bind(this); this.oAuthLoginAsync = this.oAuthLoginAsync.bind(this);
@ -137,23 +136,6 @@ class AuthenticationController {
} }
} }
async checkForLatestVersionAsync() {
let versionInfo = {
UpdateAvailable: false,
LatestVersion: '',
};
try {
const versionStatus = await this.StatusService.version();
if (versionStatus.UpdateAvailable) {
versionInfo.UpdateAvailable = true;
versionInfo.LatestVersion = versionStatus.LatestVersion;
}
} finally {
this.StateManager.setVersionInfo(versionInfo);
}
}
async postLoginSteps() { async postLoginSteps() {
await this.StateManager.initialize(); await this.StateManager.initialize();
@ -161,7 +143,6 @@ class AuthenticationController {
this.$analytics.setUserRole(isAdmin ? 'admin' : 'standard-user'); this.$analytics.setUserRole(isAdmin ? 'admin' : 'standard-user');
await this.checkForEndpointsAsync(); await this.checkForEndpointsAsync();
await this.checkForLatestVersionAsync();
} }
/** /**
* END POST LOGIN STEPS SECTION * END POST LOGIN STEPS SECTION

View File

@ -1,39 +1,14 @@
angular.module('portainer.app').controller('MainController', [ angular.module('portainer.app').controller('MainController', MainController);
'$scope',
'LocalStorage',
'StateManager',
'EndpointProvider',
'ThemeManager',
function ($scope, LocalStorage, StateManager, EndpointProvider, ThemeManager) {
/**
* Sidebar Toggle & Cookie Control
*/
var mobileView = 992;
$scope.getWidth = function () {
return window.innerWidth;
};
$scope.applicationState = StateManager.getState(); /* @ngInject */
$scope.endpointState = EndpointProvider.endpoint(); function MainController($scope, StateManager, ThemeManager, SidebarService) {
/**
* Sidebar Toggle & Cookie Control
*/
$scope.$watch($scope.getWidth, function (newValue) { $scope.applicationState = StateManager.getState();
if (newValue >= mobileView) {
const toggleValue = LocalStorage.getToolbarToggle();
$scope.toggle = typeof toggleValue === 'boolean' ? toggleValue : !window.ddExtension;
} else {
$scope.toggle = false;
}
});
$scope.toggleSidebar = function () { $scope.isSidebarOpen = SidebarService.isSidebarOpen;
$scope.toggle = !$scope.toggle;
LocalStorage.storeToolbarToggle($scope.toggle);
};
window.onresize = function () { ThemeManager.autoTheme();
$scope.$apply(); }
};
ThemeManager.autoTheme();
},
]);

View File

@ -1,131 +0,0 @@
<!-- Sidebar -->
<div id="sidebar-wrapper">
<div class="sidebar-header">
<a ui-sref="portainer.home" data-cy="portainerSidebar-homeImage">
<img ng-if="logo" ng-src="{{ logo }}" class="img-responsive logo" />
<img ng-if="!logo" src="~@/assets/images/logo_alt.svg" class="img-responsive logo" alt="Portainer" />
</a>
<a ng-click="toggleSidebar()"><span class="menu-icon glyphicon glyphicon-transfer"></span></a>
</div>
<div class="sidebar-content">
<ul class="sidebar">
<sidebar-menu-item path="portainer.home" icon-class="fa-home fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-home" title="Home">Home</sidebar-menu-item>
<li class="sidebar-title endpoint-name" ng-if="applicationState.endpoint.name">
<span class="fa fa-plug space-right"></span>{{ applicationState.endpoint.name }}
<kubectl-shell ng-if="applicationState.endpoint.mode && applicationState.endpoint.mode.provider === 'KUBERNETES'" class="kubectl-shell"></kubectl-shell>
</li>
<div ng-if="applicationState.endpoint.mode">
<kubernetes-sidebar
ng-if="applicationState.endpoint.mode.provider === 'KUBERNETES'"
is-sidebar-open="toggle"
endpoint-id="endpointId"
admin-access="isAdmin"
></kubernetes-sidebar>
<azure-sidebar ng-if="applicationState.endpoint.mode.provider === 'AZURE'" environment-id="endpointId"> </azure-sidebar>
<docker-sidebar
ng-if="applicationState.endpoint.mode.provider !== 'AZURE' && applicationState.endpoint.mode.provider !== 'KUBERNETES'"
current-route-name="$state.current.name"
is-sidebar-open="toggle"
show-stacks="showStacks"
endpoint-api-version="applicationState.endpoint.apiVersion"
swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'"
standalone-management="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'"
admin-access="isAdmin || isEndpointAdmin"
offline-mode="endpointState.OfflineMode"
endpoint-id="endpointId"
></docker-sidebar>
</div>
<sidebar-section title="Edge compute" ng-if="isAdmin && applicationState.application.enableEdgeComputeFeatures">
<sidebar-menu-item path="edge.devices" icon-class="fas fa-laptop-code fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeDevices" title="Edge Devices"
>Edge Devices</sidebar-menu-item
>
<sidebar-menu-item path="edge.groups" icon-class="fa-object-group fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeGroups" title="Edge Groups"
>Edge Groups</sidebar-menu-item
>
<sidebar-menu-item path="edge.stacks" icon-class="fa-layer-group fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeStacks" title="Edge Stacks"
>Edge Stacks</sidebar-menu-item
>
<sidebar-menu-item path="edge.jobs" icon-class="fa-clock fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-edgeJobs" title="Edge Jobs"
>Edge Jobs</sidebar-menu-item
>
</sidebar-section>
<sidebar-section ng-if="showUsersSection" title="Settings">
<sidebar-menu
ng-show="display"
icon-class="fa-users fa-fw"
label="Users"
path="portainer.users"
is-sidebar-open="toggle"
children-paths="['portainer.users.user' ,'portainer.teams' ,'portainer.teams.team' ,'portainer.roles' ,'portainer.roles.role' ,'portainer.roles.new']"
>
<sidebar-menu-item path="portainer.teams" class-name="sidebar-sublist" data-cy="portainerSidebar-teams" title="Teams">Teams</sidebar-menu-item>
<sidebar-menu-item path="portainer.roles" class-name="sidebar-sublist" data-cy="portainerSidebar-roles" title="Roles">Roles</sidebar-menu-item>
</sidebar-menu>
<div ng-if="isAdmin">
<sidebar-menu
icon-class="fa-plug fa-fw"
label="Environments"
path="portainer.endpoints"
is-sidebar-open="toggle"
children-paths="['portainer.endpoints.endpoint', 'portainer.wizard', 'portainer.endpoints.endpoint.access', 'portainer.groups', 'portainer.groups.group', 'portainer.groups.group.access', 'portainer.groups.new', 'portainer.tags']"
>
<sidebar-menu-item path="portainer.groups" class-name="sidebar-sublist" data-cy="portainerSidebar-endpointGroups" title="Groups">Groups</sidebar-menu-item>
<sidebar-menu-item path="portainer.tags" class-name="sidebar-sublist" data-cy="portainerSidebar-endpointTags" title="Tags">Tags</sidebar-menu-item>
</sidebar-menu>
<sidebar-menu-item path="portainer.registries" icon-class="fa-database fa-fw" class-name="sidebar-list" data-cy="portainerSidebar-registries" title="Registries"
>Registries</sidebar-menu-item
>
<sidebar-menu label="Authentication logs" icon-class="fa-history fa-fw" path="portainer.authLogs" is-sidebar-open="toggle" children-paths="['portainer.activityLogs']">
<sidebar-menu-item path="portainer.activityLogs" class-name="sidebar-sublist" data-cy="portainerSidebar-activityLogs" title="Activity Logs"
>Activity Logs</sidebar-menu-item
>
</sidebar-menu>
<sidebar-menu
label="Settings"
icon-class="fa-cogs fa-fw"
path="portainer.settings"
is-sidebar-open="toggle"
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"
ng-show="display"
>Authentication</sidebar-menu-item
>
<sidebar-menu-item path="portainer.settings.edgeCompute" class-name="sidebar-sublist" data-cy="portainerSidebar-edge-compute" title="Edge Compute"
>Edge Compute</sidebar-menu-item
>
<div class="sidebar-sublist">
<a href="https://www.portainer.io/community_help" target="_blank" data-cy="portainerSidebar-help" title="Help / About">Help / About</a>
</div>
</sidebar-menu>
</div>
</sidebar-section>
</ul>
<div class="sidebar-footer-content">
<div class="update-notification" ng-if="applicationState.application.versionStatus.UpdateAvailable">
<a target="_blank" href="https://github.com/portainer/portainer/releases/tag/{{ applicationState.application.versionStatus.LatestVersion }}" style="color: #091e5d">
<i class="fa-lg fas fa-cloud-download-alt" style="margin-right: 2px"></i> A new version is available
</a>
</div>
<div>
<img src="~@/assets/images/logo_small.png" class="img-responsive logo" alt="Portainer" />
<span class="version" data-cy="portainerSidebar-versionNumber">{{ uiVersion }}</span>
</div>
</div>
</div>
</div>
<!-- End Sidebar -->

View File

@ -1,70 +0,0 @@
angular.module('portainer.app').controller('SidebarController', SidebarController);
function SidebarController($rootScope, $scope, $transitions, StateManager, Notifications, Authentication, UserService, EndpointProvider, SettingsService) {
$scope.applicationState = StateManager.getState();
$scope.endpointState = EndpointProvider.endpoint();
$scope.display = !window.ddExtension;
function checkPermissions(memberships) {
var isLeader = false;
angular.forEach(memberships, function (membership) {
if (membership.Role === 1) {
isLeader = true;
}
});
$scope.isTeamLeader = isLeader;
}
function isClusterAdmin() {
return Authentication.isAdmin();
}
async function initView() {
$scope.uiVersion = StateManager.getState().application.version;
$scope.logo = StateManager.getState().application.logo;
$scope.endpointId = EndpointProvider.endpointID();
$scope.showStacks = shouldShowStacks();
const userDetails = Authentication.getUserDetails();
const isAdmin = isClusterAdmin();
$scope.isAdmin = isAdmin;
$scope.showUsersSection = isAdmin;
if (!isAdmin) {
try {
const memberships = await UserService.userMemberships(userDetails.ID);
checkPermissions(memberships);
const settings = await SettingsService.publicSettings();
$scope.showUsersSection = $scope.isTeamLeader && !settings.TeamSync;
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve user memberships');
}
}
}
initView();
function shouldShowStacks() {
if (isClusterAdmin()) {
return true;
}
const endpoint = EndpointProvider.currentEndpoint();
if (!endpoint || !endpoint.SecuritySettings) {
return false;
}
return endpoint.SecuritySettings.allowStackManagementForRegularUsers;
}
$transitions.onEnter({}, async () => {
$scope.endpointId = EndpointProvider.endpointID();
$scope.showStacks = shouldShowStacks();
$scope.isAdmin = isClusterAdmin();
if ($scope.applicationState.endpoint.name) {
document.title = `${$rootScope.defaultTitle} | ${$scope.applicationState.endpoint.name}`;
}
});
}

View File

@ -80,5 +80,16 @@ export function createMockEnvironment(): Environment {
CommandInterval: 0, CommandInterval: 0,
SnapshotInterval: 0, SnapshotInterval: 0,
}, },
SecuritySettings: {
allowBindMountsForRegularUsers: false,
allowPrivilegedModeForRegularUsers: false,
allowContainerCapabilitiesForRegularUsers: false,
allowDeviceMappingForRegularUsers: false,
allowHostNamespaceForRegularUsers: false,
allowStackManagementForRegularUsers: false,
allowSysctlSettingForRegularUsers: false,
allowVolumeBrowserForRegularUsers: false,
enableHostManagementFeatures: false,
},
}; };
} }

View File

@ -2,10 +2,6 @@
padding-top: 7px; padding-top: 7px;
} }
body.hamburg .row.header .meta {
margin-left: 70px;
}
.row.header { .row.header {
min-height: 60px; min-height: 60px;
background: var(--bg-row-header-color); background: var(--bg-row-header-color);

View File

@ -1,6 +1,8 @@
import { MouseEventHandler, PropsWithChildren } from 'react'; import { AriaAttributes, MouseEventHandler, PropsWithChildren } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
type Type = 'submit' | 'button' | 'reset'; type Type = 'submit' | 'button' | 'reset';
type Color = type Color =
| 'default' | 'default'
@ -13,13 +15,12 @@ type Color =
| 'dangerlight'; | 'dangerlight';
type Size = 'xsmall' | 'small' | 'medium' | 'large'; type Size = 'xsmall' | 'small' | 'medium' | 'large';
export interface Props { export interface Props extends AriaAttributes, AutomationTestingProps {
color?: Color; color?: Color;
size?: Size; size?: Size;
disabled?: boolean; disabled?: boolean;
title?: string; title?: string;
className?: string; className?: string;
dataCy?: string;
type?: Type; type?: Type;
onClick?: MouseEventHandler<HTMLButtonElement>; onClick?: MouseEventHandler<HTMLButtonElement>;
} }
@ -30,20 +31,21 @@ export function Button({
size = 'small', size = 'small',
disabled = false, disabled = false,
className, className,
dataCy,
onClick, onClick,
title, title,
children, children,
...ariaProps
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
return ( return (
<button <button
data-cy={dataCy}
/* eslint-disable-next-line react/button-has-type */ /* eslint-disable-next-line react/button-has-type */
type={type} type={type}
disabled={disabled} disabled={disabled}
className={clsx('btn', `btn-${color}`, sizeClass(size), className)} className={clsx('btn', `btn-${color}`, sizeClass(size), className)}
onClick={onClick} onClick={onClick}
title={title} title={title}
// eslint-disable-next-line react/jsx-props-no-spreading
{...ariaProps}
> >
{children} {children}
</button> </button>

View File

@ -1,3 +1,5 @@
import { UserContext } from '@/portainer/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { render, within } from '@/react-tools/test-utils'; import { render, within } from '@/react-tools/test-utils';
import { AzureSidebar } from './AzureSidebar'; import { AzureSidebar } from './AzureSidebar';
@ -15,7 +17,7 @@ test('dashboard items should render correctly', () => {
'fa-fw' 'fa-fw'
); );
const containerInstancesItem = getByLabelText('ContainerInstances'); const containerInstancesItem = getByLabelText(/Container Instances/i);
expect(containerInstancesItem).toBeVisible(); expect(containerInstancesItem).toBeVisible();
expect(containerInstancesItem).toHaveTextContent('Container instances'); expect(containerInstancesItem).toHaveTextContent('Container instances');
@ -30,5 +32,11 @@ test('dashboard items should render correctly', () => {
}); });
function renderComponent() { function renderComponent() {
return render(<AzureSidebar environmentId={1} />); const user = new UserViewModel({ Username: 'user' });
return render(
<UserContext.Provider value={{ user }}>
<AzureSidebar environmentId={1} />
</UserContext.Provider>
);
} }

View File

@ -0,0 +1,26 @@
import { EnvironmentId } from '@/portainer/environments/types';
import { SidebarItem } from '../SidebarItem';
interface Props {
environmentId: EnvironmentId;
}
export function AzureSidebar({ environmentId }: Props) {
return (
<>
<SidebarItem
to="azure.dashboard"
params={{ endpointId: environmentId }}
iconClass="fa-tachometer-alt fa-fw"
label="Dashboard"
/>
<SidebarItem
to="azure.containerinstances"
params={{ endpointId: environmentId }}
iconClass="fa-cubes fa-fw"
label="Container instances"
/>
</>
);
}

View File

@ -0,0 +1 @@
export { AzureSidebar } from './AzureSidebar';

View File

@ -0,0 +1,175 @@
import {
type Environment,
type EnvironmentId,
EnvironmentStatus,
} from '@/portainer/environments/types';
import {
Authorized,
useUser,
isEnvironmentAdmin,
} from '@/portainer/hooks/useUser';
import { useInfo, useVersion } from '@/docker/services/system.service';
import { SidebarItem } from './SidebarItem';
interface Props {
environmentId: EnvironmentId;
environment: Environment;
}
export function DockerSidebar({ environmentId, environment }: Props) {
const { user } = useUser();
const isAdmin = isEnvironmentAdmin(user, environmentId);
const areStacksVisible =
isAdmin || environment.SecuritySettings.allowStackManagementForRegularUsers;
const envInfoQuery = useInfo(
environmentId,
(info) => !!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable
);
const envVersionQuery = useVersion(environmentId, (version) =>
parseFloat(version.ApiVersion)
);
const isSwarmManager = envInfoQuery.data;
const apiVersion = envVersionQuery.data || 0;
const offlineMode = environment.Status === EnvironmentStatus.Down;
const setupSubMenuProps = isSwarmManager
? {
label: 'Swarm',
iconClass: 'fa-object-group fa-fw',
to: 'docker.swarm',
}
: {
label: 'Host',
iconClass: 'fa-th fa-fw',
to: 'docker.host',
};
return (
<>
<SidebarItem
to="docker.dashboard"
params={{ endpointId: environmentId }}
iconClass="fa-tachometer-alt fa-fw"
label="Dashboard"
/>
{!offlineMode && (
<SidebarItem
label="App Templates"
iconClass="fa-rocket fa-fw"
to="docker.templates"
params={{ endpointId: environmentId }}
>
<SidebarItem
label="Custom Templates"
to="docker.templates.custom"
params={{ endpointId: environmentId }}
/>
</SidebarItem>
)}
{areStacksVisible && (
<SidebarItem
to="docker.stacks"
params={{ endpointId: environmentId }}
iconClass="fa-th-list fa-fw"
label="Stacks"
/>
)}
{isSwarmManager && (
<SidebarItem
to="docker.services"
params={{ endpointId: environmentId }}
iconClass="fa-list-alt fa-fw"
label="Services"
/>
)}
<SidebarItem
to="docker.containers"
params={{ endpointId: environmentId }}
iconClass="fa-cubes fa-fw"
label="Containers"
/>
<SidebarItem
to="docker.images"
params={{ endpointId: environmentId }}
iconClass="fa-clone fa-fw"
label="Images"
/>
<SidebarItem
to="docker.networks"
params={{ endpointId: environmentId }}
iconClass="fa-sitemap fa-fw"
label="Networks"
/>
<SidebarItem
to="docker.volumes"
params={{ endpointId: environmentId }}
iconClass="fa-hdd fa-fw"
label="Volumes"
/>
{apiVersion >= 1.3 && isSwarmManager && (
<SidebarItem
to="docker.configs"
params={{ endpointId: environmentId }}
iconClass="fa-file-code fa-fw"
label="Configs"
/>
)}
{apiVersion >= 1.25 && isSwarmManager && (
<SidebarItem
to="docker.secrets"
params={{ endpointId: environmentId }}
iconClass="fa-user-secret fa-fw"
label="Secrets"
/>
)}
{!isSwarmManager && isAdmin && !offlineMode && (
<SidebarItem
to="docker.events"
params={{ endpointId: environmentId }}
iconClass="fa-history fa-fw"
label="Events"
/>
)}
<SidebarItem
label={setupSubMenuProps.label}
iconClass={setupSubMenuProps.iconClass}
to={setupSubMenuProps.to}
params={{ endpointId: environmentId }}
>
<Authorized
authorizations="PortainerEndpointUpdateSettings"
adminOnlyCE
>
<SidebarItem
to="docker.featuresConfiguration"
params={{ endpointId: environmentId }}
label="Setup"
/>
</Authorized>
<SidebarItem
to="docker.registries"
params={{ endpointId: environmentId }}
label="Registries"
/>
</SidebarItem>
</>
);
}

View File

@ -0,0 +1,29 @@
import { SidebarItem } from './SidebarItem';
import { SidebarSection } from './SidebarSection';
export function EdgeComputeSidebar() {
return (
<SidebarSection title="Edge compute">
<SidebarItem
to="edge.devices"
iconClass="fas fa-laptop-code fa-fw"
label="Edge Devices"
/>
<SidebarItem
to="edge.groups"
iconClass="fa-object-group fa-fw"
label="Edge Groups"
/>
<SidebarItem
to="edge.stacks"
iconClass="fa-layer-group fa-fw"
label="Edge Stacks"
/>
<SidebarItem
to="edge.jobs"
iconClass="fa-clock fa-fw"
label="Edge Jobs"
/>
</SidebarSection>
);
}

View File

@ -0,0 +1,5 @@
.title {
color: #fff;
text-align: center;
text-indent: 0;
}

View File

@ -0,0 +1,75 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { useEffect, useState } from 'react';
import {
PlatformType,
EnvironmentId,
Environment,
} from '@/portainer/environments/types';
import { getPlatformType } from '@/portainer/environments/utils';
import { useEnvironment } from '@/portainer/environments/queries/useEnvironment';
import { AzureSidebar } from './AzureSidebar';
import { DockerSidebar } from './DockerSidebar';
import { KubernetesSidebar } from './KubernetesSidebar';
import { SidebarSection } from './SidebarSection';
import styles from './EnvironmentSidebar.module.css';
export function EnvironmentSidebar() {
const currentEnvironmentQuery = useCurrentEnvironment();
const environment = currentEnvironmentQuery.data;
if (!environment) {
return null;
}
const platform = getPlatformType(environment.Type);
const sidebar = getSidebar(environment);
return (
<SidebarSection
title={
<div className={styles.title}>
<i className="fa fa-plug space-right" />
{environment.Name}
</div>
}
label={PlatformType[platform]}
>
{sidebar}
</SidebarSection>
);
function getSidebar(environment: Environment) {
switch (platform) {
case PlatformType.Azure:
return <AzureSidebar environmentId={environment.Id} />;
case PlatformType.Docker:
return (
<DockerSidebar
environmentId={environment.Id}
environment={environment}
/>
);
case PlatformType.Kubernetes:
return <KubernetesSidebar environmentId={environment.Id} />;
default:
return null;
}
}
}
function useCurrentEnvironment() {
const { params } = useCurrentStateAndParams();
const [environmentId, setEnvironmentId] = useState<EnvironmentId>();
useEffect(() => {
const environmentId = parseInt(params.endpointId, 10);
if (params.endpointId && !Number.isNaN(environmentId)) {
setEnvironmentId(environmentId);
}
}, [params.endpointId]);
return useEnvironment(environmentId);
}

View File

@ -0,0 +1,28 @@
:global(#page-wrapper:not(.open)) .root {
display: none;
}
.root {
text-align: center;
}
.logo {
display: inline;
width: 100%;
max-width: 100px;
height: 100%;
max-height: 35px;
margin: 2px 0 2px 20px;
}
.version {
font-size: 11px;
margin: 11px 20px 0 7px;
color: #fff;
}
.edition-version {
font-size: 10px;
margin-bottom: 8px;
color: #fff;
}

View File

@ -0,0 +1,44 @@
import { useQuery } from 'react-query';
import clsx from 'clsx';
import smallLogo from '@/assets/images/logo_small.png';
import { getStatus } from '@/portainer/services/api/status.service';
import { UpdateNotification } from './UpdateNotifications';
import styles from './Footer.module.css';
export function Footer() {
const statusQuery = useStatus();
if (!statusQuery.data) {
return null;
}
const { Edition, Version } = statusQuery.data;
return (
<div className={styles.root}>
{process.env.PORTAINER_EDITION === 'CE' && <UpdateNotification />}
<div>
<img
src={smallLogo}
className={clsx('img-responsive', styles.logo)}
alt="Portainer"
/>
<span
className={styles.version}
data-cy="portainerSidebar-versionNumber"
>
{Version}
</span>
{process.env.PORTAINER_EDITION !== 'CE' && (
<div className={styles.editionVersion}>{Edition}</div>
)}
</div>
</div>
);
}
function useStatus() {
return useQuery(['status'], () => getStatus());
}

View File

@ -0,0 +1,29 @@
.root {
color: white;
}
.toggle-button {
border: 0;
margin: 0;
padding: 0;
background: initial;
float: right;
padding-right: 28px;
line-height: 60px;
}
.root {
height: 60px;
list-style: none;
text-indent: 20px;
font-size: 18px;
background: var(--bg-sidebar-header-color);
}
.root a {
color: #fff;
}
.root a:hover {
text-decoration: none;
}

View File

@ -0,0 +1,37 @@
import defaultLogo from '@/assets/images/logo.png';
import { Link } from '@@/Link';
import styles from './Header.module.css';
import { useSidebarState } from './useSidebarState';
interface Props {
logo?: string;
}
export function Header({ logo }: Props) {
const { toggle } = useSidebarState();
return (
<div className={styles.root}>
<Link to="portainer.home" data-cy="portainerSidebar-homeImage">
<img
src={logo || defaultLogo}
className="img-responsive logo"
alt={!logo ? 'Portainer' : ''}
/>
</Link>
{toggle && (
<button
type="button"
onClick={() => toggle()}
className={styles.toggleButton}
aria-label="Toggle Sidebar"
title="Toggle Sidebar"
>
<i className="glyphicon glyphicon-transfer" />
</button>
)}
</div>
);
}

View File

@ -0,0 +1,40 @@
.root {
position: fixed;
background: #000;
bottom: 0;
left: 0;
width: 100vw;
z-index: 1000;
height: 495px;
}
.root.minimized {
height: 35px;
}
.header {
height: 35px;
display: flex;
justify-content: space-between;
align-items: center;
color: #424242;
background: rgb(245, 245, 245);
border-top: 1px solid rgb(190, 190, 190);
padding: 0 30px;
}
.title {
font-weight: bold;
font-size: 14px;
}
.actions button {
padding: 0;
border: 0;
}
.terminal-container .loading-message {
position: fixed;
color: #fff;
}

View File

@ -0,0 +1,191 @@
import { Terminal } from 'xterm';
import { fit } from 'xterm/lib/addons/fit/fit';
import { useCallback, useEffect, useRef, useState } from 'react';
import clsx from 'clsx';
import { baseHref } from '@/portainer/helpers/pathHelper';
import {
terminalClose,
terminalResize,
} from '@/portainer/services/terminal-window';
import { EnvironmentId } from '@/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
import { useLocalStorage } from '@/portainer/hooks/useLocalStorage';
import { Button } from '@@/buttons';
import styles from './KubectlShell.module.css';
interface ShellState {
socket: WebSocket | null;
minimized: boolean;
}
interface Props {
environmentId: EnvironmentId;
onClose(): void;
}
export function KubeCtlShell({ environmentId, onClose }: Props) {
const [terminal] = useState(new Terminal());
const [shell, setShell] = useState<ShellState>({
socket: null,
minimized: false,
});
const { socket } = shell;
const terminalElem = useRef(null);
const [jwt] = useLocalStorage('JWT', '');
const handleClose = useCallback(() => {
terminalClose(); // only css trick
socket?.close();
terminal.dispose();
onClose();
}, [onClose, terminal, socket]);
const openTerminal = useCallback(() => {
if (!terminalElem.current) {
return;
}
terminal.open(terminalElem.current);
terminal.setOption('cursorBlink', true);
terminal.focus();
fit(terminal);
terminal.writeln('#Run kubectl commands inside here');
terminal.writeln('#e.g. kubectl get all');
terminal.writeln('');
}, [terminal]);
// refresh socket listeners on socket updates
useEffect(() => {
if (!socket) {
return () => {};
}
function onOpen() {
openTerminal();
}
function onMessage(e: MessageEvent) {
terminal.write(e.data);
}
function onClose() {
handleClose();
}
function onError(e: Event) {
handleClose();
if (socket?.readyState !== WebSocket.CLOSED) {
notifyError(
'Failure',
e as unknown as Error,
'Websocket connection error'
);
}
}
socket.addEventListener('open', onOpen);
socket.addEventListener('message', onMessage);
socket.addEventListener('close', onClose);
socket.addEventListener('error', onError);
return () => {
socket.removeEventListener('open', onOpen);
socket.removeEventListener('message', onMessage);
socket.removeEventListener('close', onClose);
socket.removeEventListener('error', onError);
};
}, [handleClose, openTerminal, socket, terminal]);
// on component load/destroy
useEffect(() => {
const socket = new WebSocket(buildUrl(jwt, environmentId));
setShell((shell) => ({ ...shell, socket }));
terminal.onData((data) => socket.send(data));
terminal.onKey(({ domEvent }) => {
if (domEvent.ctrlKey && domEvent.code === 'KeyD') {
close();
}
});
window.addEventListener('resize', () => terminalResize());
function close() {
socket.close();
terminal.dispose();
window.removeEventListener('resize', terminalResize);
}
return close;
}, [environmentId, jwt, terminal]);
return (
<div className={clsx(styles.root, { [styles.minimized]: shell.minimized })}>
<div className={styles.header}>
<div className={styles.title}>
<i className="fas fa-terminal space-right" />
kubectl shell
</div>
<div className={clsx(styles.actions, 'space-x-8')}>
<Button color="link" onClick={clearScreen}>
<i className="fas fa-redo-alt" data-cy="k8sShell-refreshButton" />
</Button>
<Button color="link" onClick={toggleMinimize}>
<i
className={clsx(
'fas',
shell.minimized ? 'fa-window-restore' : 'fa-window-minimize'
)}
data-cy={
shell.minimized ? 'k8sShell-restore' : 'k8sShell-minimise'
}
/>
</Button>
<Button color="link" onClick={handleClose}>
<i className="fas fa-times" data-cy="k8sShell-closeButton" />
</Button>
</div>
</div>
<div className={styles.terminalContainer} ref={terminalElem}>
<div className={styles.loadingMessage}>Loading Terminal...</div>
</div>
</div>
);
function clearScreen() {
terminal.clear();
}
function toggleMinimize() {
if (shell.minimized) {
terminalResize();
setShell((shell) => ({ ...shell, minimized: false }));
} else {
terminalClose();
setShell((shell) => ({ ...shell, minimized: true }));
}
}
function buildUrl(jwt: string, environmentId: EnvironmentId) {
const params = {
token: jwt,
endpointId: environmentId,
};
const wsProtocol =
window.location.protocol === 'https:' ? 'wss://' : 'ws://';
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)
.map(([k, v]) => `${k}=${v}`)
.join('&');
return `${wsProtocol}${base}?${queryParams}`;
}
}

View File

@ -0,0 +1,5 @@
.root {
display: block;
margin: 0 auto;
padding-bottom: 5px;
}

View File

@ -0,0 +1,47 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { EnvironmentId } from '@/portainer/environments/types';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { Button } from '@@/buttons';
import { KubeCtlShell } from './KubectlShell';
import styles from './KubectlShellButton.module.css';
interface Props {
environmentId: EnvironmentId;
}
export function KubectlShellButton({ environmentId }: Props) {
const [open, setOpen] = useState(false);
const { trackEvent } = useAnalytics();
return (
<>
<Button
color="primary"
size="xsmall"
disabled={open}
data-cy="k8sSidebar-shellButton"
onClick={() => handleOpen()}
className={styles.root}
>
<i className="fa fa-terminal space-right" /> kubectl shell
</Button>
{open &&
createPortal(
<KubeCtlShell
environmentId={environmentId}
onClose={() => setOpen(false)}
/>,
document.body
)}
</>
);
function handleOpen() {
setOpen(true);
trackEvent('kubernetes-kubectl-shell', { category: 'kubernetes' });
}
}

View File

@ -0,0 +1 @@
export { KubectlShellButton } from './KubectlShellButton';

View File

@ -0,0 +1,98 @@
import { EnvironmentId } from '@/portainer/environments/types';
import { Authorized } from '@/portainer/hooks/useUser';
import { SidebarItem } from '../SidebarItem';
import { KubectlShellButton } from './KubectlShell';
interface Props {
environmentId: EnvironmentId;
}
export function KubernetesSidebar({ environmentId }: Props) {
return (
<>
<KubectlShellButton environmentId={environmentId} />
<SidebarItem
to="kubernetes.dashboard"
params={{ endpointId: environmentId }}
iconClass="fa-tachometer-alt fa-fw"
label="Dashboard"
/>
<SidebarItem
to="kubernetes.templates.custom"
params={{ endpointId: environmentId }}
iconClass="fa-rocket fa-fw"
label="Custom Templates"
/>
<SidebarItem
to="kubernetes.resourcePools"
params={{ endpointId: environmentId }}
iconClass="fa-layer-group fa-fw"
label="Namespaces"
/>
<Authorized authorizations="HelmInstallChart">
<SidebarItem
to="kubernetes.templates.helm"
params={{ endpointId: environmentId }}
iconClass="fa-dharmachakra fa-fw"
label="Helm"
/>
</Authorized>
<SidebarItem
to="kubernetes.applications"
params={{ endpointId: environmentId }}
iconClass="fa-laptop-code fa-fw"
label="Applications"
/>
<SidebarItem
to="kubernetes.configurations"
params={{ endpointId: environmentId }}
iconClass="fa-file-code fa-fw"
label="ConfigMaps & Secrets"
/>
<SidebarItem
to="kubernetes.volumes"
params={{ endpointId: environmentId }}
iconClass="fa-database fa-fw"
label="Volumes"
/>
<SidebarItem
iconClass="fa-server fa-fw"
label="Cluster"
to="kubernetes.cluster"
params={{ endpointId: environmentId }}
>
<Authorized authorizations="K8sClusterSetupRW" adminOnlyCE>
<SidebarItem
to="portainer.k8sendpoint.kubernetesConfig"
params={{ id: environmentId }}
label="Setup"
/>
</Authorized>
<Authorized authorizations="K8sClusterSetupRW" adminOnlyCE>
<SidebarItem
to="portainer.k8sendpoint.securityConstraint"
params={{ id: environmentId }}
label="Security constraints"
/>
</Authorized>
<SidebarItem
to="kubernetes.registries"
params={{ endpointId: environmentId }}
label="Registries"
/>
</SidebarItem>
</>
);
}

View File

@ -0,0 +1 @@
export { KubernetesSidebar } from './KubernetesSidebar';

View File

@ -0,0 +1,93 @@
import { usePublicSettings } from '@/portainer/settings/queries';
import { SidebarItem } from './SidebarItem';
import { SidebarSection } from './SidebarSection';
interface Props {
isAdmin: boolean;
}
export function SettingsSidebar({ isAdmin }: Props) {
const teamSyncQuery = usePublicSettings<boolean>(
(settings) => settings.TeamSync
);
const showUsersSection =
!window.ddExtension && (isAdmin || teamSyncQuery.data);
return (
<SidebarSection title="Settings">
{showUsersSection && (
<SidebarItem
to="portainer.users"
label="Users"
iconClass="fa-users fa-fw"
>
<SidebarItem to="portainer.teams" label="Teams" />
{isAdmin && <SidebarItem to="portainer.roles" label="Roles" />}
</SidebarItem>
)}
{isAdmin && (
<>
<SidebarItem
label="Environments"
to="portainer.endpoints"
iconClass="fa-plug fa-fw"
openOnPaths={['portainer.wizard.endpoints']}
>
<SidebarItem to="portainer.groups" label="Groups" />
<SidebarItem to="portainer.tags" label="Tags" />
</SidebarItem>
<SidebarItem
label="Registries"
to="portainer.registries"
iconClass="fa-database fa-fw"
/>
<SidebarItem
label="Authentication logs"
to="portainer.authLogs"
iconClass="fa-history fa-fw"
>
<SidebarItem to="portainer.activityLogs" label="Activity Logs" />
</SidebarItem>
<SidebarItem
to="portainer.settings"
label="Settings"
iconClass="fa-cogs fa-fw"
>
{!window.ddExtension && (
<SidebarItem
to="portainer.settings.authentication"
label="Authentication"
/>
)}
<SidebarItem to="portainer.settings.cloud" label="Cloud" />
<SidebarItem
to="portainer.settings.edgeCompute"
label="Edge Compute"
/>
<SidebarItem.Wrapper label="Help / About">
<a
href={
process.env.PORTAINER_EDITION === 'CE'
? 'https://www.portainer.io/community_help'
: 'https://documentation.portainer.io/r/business-support'
}
target="_blank"
rel="noreferrer"
>
Help / About
</a>
</SidebarItem.Wrapper>
</SidebarItem>
</>
)}
</SidebarSection>
);
}

View File

@ -0,0 +1,56 @@
:global(#page-wrapper.open) .root {
left: 150px;
}
.root {
margin-left: -150px;
left: -30px;
width: 250px;
position: fixed;
height: 100%;
z-index: 999;
transition: all 0.4s ease 0s;
display: flex;
flex-flow: column;
}
.root {
background-color: var(--blue-5);
}
:global(:root[theme='dark']) .root {
background-color: var(--grey-1);
}
:global(:root[theme='highcontrast']) .root {
background-color: var(--black-color);
}
:global(:root[data-edition='BE']) .root {
background-color: var(--grey-5);
}
:global(:root[data-edition='BE'][theme='dark']) .root {
background-color: var(--grey-1);
}
:global(:root[data-edition='BE'][theme='highcontrast']) .root {
background-color: var(--black-color);
}
ul.sidebar {
top: 0;
bottom: 0;
padding: 0;
margin: 0;
list-style: none;
text-indent: 20px;
position: relative;
overflow: hidden;
flex-shrink: 0;
}
.sidebar-content {
display: flex;
flex-direction: column;
justify-content: space-between;
overflow-y: auto;
overflow-x: hidden;
height: 100%;
}

View File

@ -0,0 +1,51 @@
import { useUser } from '@/portainer/hooks/useUser';
import { useIsTeamLeader } from '@/portainer/users/queries';
import { usePublicSettings } from '@/portainer/settings/queries';
import styles from './Sidebar.module.css';
import { EdgeComputeSidebar } from './EdgeComputeSidebar';
import { EnvironmentSidebar } from './EnvironmentSidebar';
import { SettingsSidebar } from './SettingsSidebar';
import { SidebarItem } from './SidebarItem';
import { Footer } from './Footer';
import { Header } from './Header';
import { SidebarProvider } from './useSidebarState';
export function Sidebar() {
const { isAdmin, user } = useUser();
const isTeamLeader = useIsTeamLeader(user);
const settingsQuery = usePublicSettings();
if (!settingsQuery.data) {
return null;
}
const { EnableEdgeComputeFeatures, LogoURL } = settingsQuery.data;
return (
/* in the future (when we remove r2a) this should wrap the whole app - to change root styles */
<SidebarProvider>
<nav id="sidebar-wrapper" className={styles.root} aria-label="Main">
<Header logo={LogoURL} />
<div className={styles.sidebarContent}>
<ul className={styles.sidebar}>
<SidebarItem
to="portainer.home"
iconClass="fa-home fa-fw"
label="Home"
/>
<EnvironmentSidebar />
{isAdmin && EnableEdgeComputeFeatures && <EdgeComputeSidebar />}
{(isAdmin || isTeamLeader) && <SettingsSidebar isAdmin={isAdmin} />}
</ul>
</div>
<Footer />
</nav>
</SidebarProvider>
);
}

View File

@ -0,0 +1,17 @@
.menu-icon {
padding-right: 30px;
width: 70px;
line-height: 36px;
}
@media (max-height: 785px) {
.menu-icon {
line-height: 26px;
}
}
@media (min-height: 786px) and (max-height: 924px) {
.menu-icon {
line-height: 30px;
}
}

View File

@ -0,0 +1,18 @@
import clsx from 'clsx';
import styles from './Icon.module.css';
interface Props {
iconClass: string;
}
export function Icon({ iconClass }: Props) {
return (
<i
role="img"
className={clsx('fa', iconClass, styles.menuIcon)}
aria-label="itemIcon"
aria-hidden="true"
/>
);
}

View File

@ -0,0 +1,28 @@
a.active {
color: #fff;
border-left-color: var(--border-sidebar-color);
}
a.active {
background: var(--grey-37);
}
:global(:root[theme='dark']) a.active {
background-color: var(--grey-3);
}
:global(:root[theme='highcontrast']) a.active {
background-color: var(--black-color);
}
:global(:root[data-edition='BE']) a.active {
background-color: var(--grey-5);
}
:global(:root[data-edition='BE'][theme='dark']) a.active {
background-color: var(--grey-1);
}
:global(:root[data-edition='BE'][theme='highcontrast']) a.active {
background-color: var(--black-color);
}

View File

@ -0,0 +1,43 @@
import { UISrefActive } from '@uirouter/react';
import { Children, ComponentProps, ReactNode } from 'react';
import _ from 'lodash';
import { Link as NavLink } from '@@/Link';
import styles from './Link.module.css';
interface Props extends ComponentProps<typeof NavLink> {
children: ReactNode;
}
export function Link({ children, to, options, params, title }: Props) {
const label = title || getLabel(children);
return (
<UISrefActive class={styles.active}>
<NavLink
to={to}
params={params}
className={styles.link}
title={label}
options={options}
>
{children}
</NavLink>
</UISrefActive>
);
}
function getLabel(children: ReactNode) {
return _.first(
_.compact(
Children.map(children, (child) => {
if (typeof child === 'string') {
return child;
}
return '';
})
)
);
}

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