mirror of https://github.com/portainer/portainer
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 proppull/7084/head
parent
f78a6568a6
commit
84611a90a1
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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']);
|
|
|
@ -1 +0,0 @@
|
||||||
export { AzureSidebar, AzureSidebarAngular } from './AzureSidebar';
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
|
|
@ -1,15 +0,0 @@
|
||||||
angular.module('portainer.docker').component('dockerSidebar', {
|
|
||||||
templateUrl: './docker-sidebar.html',
|
|
||||||
bindings: {
|
|
||||||
isSidebarOpen: '<',
|
|
||||||
|
|
||||||
endpointApiVersion: '<',
|
|
||||||
swarmManagement: '<',
|
|
||||||
standaloneManagement: '<',
|
|
||||||
adminAccess: '<',
|
|
||||||
offlineMode: '<',
|
|
||||||
currentRouteName: '<',
|
|
||||||
endpointId: '<',
|
|
||||||
showStacks: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -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={() => {
|
||||||
|
|
|
@ -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()}
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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>
|
|
|
@ -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,
|
|
||||||
});
|
|
|
@ -1,10 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
|
|
||||||
angular.module('portainer.kubernetes').component('kubernetesSidebar', {
|
|
||||||
templateUrl: './kubernetes-sidebar.html',
|
|
||||||
bindings: {
|
|
||||||
endpointId: '<',
|
|
||||||
isSidebarOpen: '<',
|
|
||||||
adminAccess: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -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>
|
|
|
@ -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.`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
.sidebar-menu-item > a {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
|
@ -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',
|
|
||||||
};
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
export { SidebarMenuItem } from './SidebarMenuItem';
|
|
|
@ -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;
|
|
|
@ -1,12 +0,0 @@
|
||||||
import './sidebar-menu-item.css';
|
|
||||||
|
|
||||||
export const sidebarMenuItem = {
|
|
||||||
templateUrl: './sidebar-menu-item.html',
|
|
||||||
bindings: {
|
|
||||||
path: '@',
|
|
||||||
pathParams: '<',
|
|
||||||
iconClass: '@',
|
|
||||||
className: '@',
|
|
||||||
},
|
|
||||||
transclude: true,
|
|
||||||
};
|
|
|
@ -1,4 +0,0 @@
|
||||||
.sidebar-menu-item > a {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
|
@ -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>
|
|
|
@ -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,
|
|
||||||
};
|
|
|
@ -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;
|
|
|
@ -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;
|
|
||||||
}
|
|
|
@ -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>
|
|
|
@ -1,7 +0,0 @@
|
||||||
export const sidebarSection = {
|
|
||||||
templateUrl: './sidebar-section.html',
|
|
||||||
transclude: true,
|
|
||||||
bindings: {
|
|
||||||
title: '@',
|
|
||||||
},
|
|
||||||
};
|
|
|
@ -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>
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
|
@ -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.`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { KubeconfigButton } from './KubeconfigButton';
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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';
|
|
||||||
};
|
|
||||||
});
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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..."
|
||||||
>
|
>
|
||||||
|
|
|
@ -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..."
|
||||||
>
|
>
|
||||||
|
|
|
@ -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..."
|
||||||
>
|
>
|
||||||
|
|
|
@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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..."
|
||||||
>
|
>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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')
|
||||||
);
|
);
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
|
@ -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 -->
|
|
|
@ -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}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
|
@ -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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { AzureSidebar } from './AzureSidebar';
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
.title {
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
text-indent: 0;
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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());
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
.root {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { KubectlShellButton } from './KubectlShellButton';
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { KubernetesSidebar } from './KubernetesSidebar';
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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%;
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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
Loading…
Reference in New Issue