diff --git a/app/app.js b/app/app.js index a226c7337..6627733f4 100644 --- a/app/app.js +++ b/app/app.js @@ -7,7 +7,14 @@ export function onStartupAngular($rootScope, $state, $interval, LocalStorage, En EndpointProvider.initialize(); $rootScope.$state = $state; - $rootScope.defaultTitle = document.title; + const defaultTitle = document.title; + + $transitions.onEnter({}, () => { + const endpoint = EndpointProvider.currentEndpoint(); + if (endpoint) { + document.title = `${defaultTitle} | ${endpoint.Name}`; + } + }); // Workaround to prevent the loading bar from going backward // https://github.com/chieffancypants/angular-loading-bar/issues/273 diff --git a/app/assets/css/app.css b/app/assets/css/app.css index b38fb99c3..3cc59643e 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -809,11 +809,12 @@ json-tree .branch-preview { } /* !spinkit override */ -.kubectl-shell { - display: block; - text-align: center; - padding-bottom: 5px; +/* uib-typeahead override */ +#scrollable-dropdown-menu .dropdown-menu { + max-height: 300px; + overflow-y: auto; } +/* !uib-typeahead override */ .no-margin { margin: 0 !important; diff --git a/app/assets/css/rdash.css b/app/assets/css/rdash.css index f52c20bfc..18affc7d4 100644 --- a/app/assets/css/rdash.css +++ b/app/assets/css/rdash.css @@ -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 { width: 40px; height: 40px; @@ -131,16 +98,8 @@ body { padding-left: 70px; height: 100%; } -#sidebar-wrapper { - margin-left: -150px; - left: -30px; - width: 250px; - position: fixed; - height: 100%; - z-index: 999; -} -#page-wrapper, -#sidebar-wrapper { + +#page-wrapper { transition: all 0.4s ease 0s; } .green { diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index b3a48046a..672ba2ec5 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -115,7 +115,6 @@ --bg-main-color: var(--white-color); --bg-body-color: var(--grey-9); --bg-checkbox-border-color: var(--grey-49); - --bg-sidebar-color: var(--grey-37); --bg-sidebar-header-color: var(--grey-37); --bg-widget-color: var(--white-color); --bg-widget-header-color: var(--grey-10); @@ -152,7 +151,6 @@ --bg-row-header-color: var(--white-color); --bg-image-multiselect-button: linear-gradient(var(--white-color), var(--grey-17)); --bg-multiselect-checkbox-color: var(--white-color); - --bg-sidebar-wrapper-color: var(--blue-5); --bg-panel-body-color: var(--white-color); --bg-codemirror-color: var(--white-color); --bg-codemirror-selected-color: var(--grey-22); @@ -223,14 +221,11 @@ --text-ui-select-hover-color: var(--grey-28); --text-summary-color: var(--black-color); --text-tooltip-color: var(--white-color); - --text-sidebar-list-color: var(--grey-56); - --text-rzslider-color: var(--grey-36); --text-rzslider-limit-color: var(--grey-36); --text-daterangepicker-end-date: var(--grey-57); --text-daterangepicker-in-range: var(--black-color); --text-daterangepicker-active: var(--white-color); - --text-tooltip-color: var(--grey-6); --text-input-autofill-color: var(--black-color); --text-button-hover-color: var(--grey-6); --text-small-select-color: var(--grey-25); @@ -273,7 +268,6 @@ --border-blocklist: 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-boxselector-color: 0 3px 10px -2px var(--grey-50); --blue-color: var(--blue-13); @@ -307,7 +301,6 @@ --bg-main-color: var(--grey-2); --bg-body-color: var(--grey-2); --bg-checkbox-border-color: var(--grey-8); - --bg-sidebar-color: var(--grey-3); --bg-widget-color: var(--grey-1); --bg-widget-header-color: var(--grey-1); --bg-widget-table-color: var(--grey-1); @@ -345,7 +338,6 @@ --bg-multiselect-button-color: var(--grey-3); --bg-image-multiselect-button: none !important; --bg-multiselect-checkbox-color: var(--grey-3); - --bg-sidebar-wrapper-color: var(--grey-1); --bg-panel-body-color: var(--grey-1); --bg-boxselector-wrapper-disabled-color: var(--grey-39); --bg-codemirror-selected-color: var(--grey-3); @@ -411,7 +403,6 @@ --text-summary-color: var(--white-color); --text-multiselect-button-color: var(--white-color); --text-multiselect-item-color: var(--white-color); - --text-sidebar-list-color: var(--white-color); --text-boxselector-wrapper-color: var(--white-color); --text-daterangepicker-end-date: var(--grey-7); --text-daterangepicker-in-range: var(--white-color); @@ -459,7 +450,6 @@ --border-blocklist: var(--ui-grey-3); --border-widget: var(--ui-grey-3); - --hover-sidebar-color: var(--grey-3); --blue-color: var(--blue-2); --button-close-color: var(--white-color); --button-opacity: 0.6; @@ -491,7 +481,6 @@ --bg-main-color: var(--black-color); --bg-body-color: var(--black-color); --bg-checkbox-border-color: var(--grey-8); - --bg-sidebar-color: var(--black-color); --bg-widget-color: var(--black-color); --bg-widget-header-color: var(--black-color); --bg-widget-table-color: var(--black-color); @@ -503,7 +492,6 @@ --bg-dropdown-menu-color: var(--black-color); --bg-codemirror-selected-color: var(--grey-3); --bg-row-header-color: var(--black-color); - --bg-sidebar-wrapper-color: var(--black-color); --bg-motd-body-color: var(--black-color); --bg-blocklist-hover-color: var(--black-color); --bg-blocklist-item-selected-color: var(--black-color); @@ -585,7 +573,6 @@ --text-daterangepicker-end-date: var(--grey-7); --text-daterangepicker-in-range: var(--white-color); --text-daterangepicker-active: var(--white-color); - --text-sidebar-list-color: var(--white-color); --text-ui-select-color: var(--white-color); --text-btn-default-color: var(--white-color); --text-json-tree-color: var(--white-color); @@ -634,8 +621,6 @@ --border-blocklist: 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-boxselector-color: none; diff --git a/app/azure/AzureSidebar/AzureSidebar.tsx b/app/azure/AzureSidebar/AzureSidebar.tsx deleted file mode 100644 index ca83d51b5..000000000 --- a/app/azure/AzureSidebar/AzureSidebar.tsx +++ /dev/null @@ -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 ( - <> - - Dashboard - - - Container instances - - - ); -} - -export const AzureSidebarAngular = r2a(AzureSidebar, ['environmentId']); diff --git a/app/azure/AzureSidebar/index.ts b/app/azure/AzureSidebar/index.ts deleted file mode 100644 index 23b4fe95c..000000000 --- a/app/azure/AzureSidebar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { AzureSidebar, AzureSidebarAngular } from './AzureSidebar'; diff --git a/app/azure/_module.js b/app/azure/_module.js index 898303081..90eb8f37a 100644 --- a/app/azure/_module.js +++ b/app/azure/_module.js @@ -1,6 +1,5 @@ import angular from 'angular'; -import { AzureSidebarAngular } from './AzureSidebar/AzureSidebar'; import { DashboardViewAngular } from './Dashboard/DashboardView'; import { containerInstancesModule } from './ContainerInstances'; import { reactModule } from './react'; @@ -84,5 +83,4 @@ angular $stateRegistryProvider.register(dashboard); }, ]) - .component('azureSidebar', AzureSidebarAngular) .component('dashboardView', DashboardViewAngular); diff --git a/app/docker/components/docker-sidebar/docker-sidebar.html b/app/docker/components/docker-sidebar/docker-sidebar.html deleted file mode 100644 index 8e4245eae..000000000 --- a/app/docker/components/docker-sidebar/docker-sidebar.html +++ /dev/null @@ -1,186 +0,0 @@ - - Dashboard - - - - - Custom Templates - - - - - Stacks - - - - Services - - - - Containers - - - - Images - - - - Networks - - - - Volumes - - - - Configs - - - - Secrets - - - - Events - - - - - Setup - - - - Registries - - - - - - Setup - - - - Registries - - diff --git a/app/docker/components/docker-sidebar/docker-sidebar.js b/app/docker/components/docker-sidebar/docker-sidebar.js deleted file mode 100644 index 65e679262..000000000 --- a/app/docker/components/docker-sidebar/docker-sidebar.js +++ /dev/null @@ -1,15 +0,0 @@ -angular.module('portainer.docker').component('dockerSidebar', { - templateUrl: './docker-sidebar.html', - bindings: { - isSidebarOpen: '<', - - endpointApiVersion: '<', - swarmManagement: '<', - standaloneManagement: '<', - adminAccess: '<', - offlineMode: '<', - currentRouteName: '<', - endpointId: '<', - showStacks: '<', - }, -}); diff --git a/app/docker/networks/edit/NetworkContainersTable.tsx b/app/docker/networks/edit/NetworkContainersTable.tsx index cb18ba10e..935ff29f1 100644 --- a/app/docker/networks/edit/NetworkContainersTable.tsx +++ b/app/docker/networks/edit/NetworkContainersTable.tsx @@ -66,7 +66,7 @@ export function NetworkContainersTable({ - -
-
-
kubectl shell
-
- - - -
-
-
-
-
-
-
Loading Terminal...
-
-
-
-
-
diff --git a/app/kubernetes/components/kubectl-shell/kubectl-shell.js b/app/kubernetes/components/kubectl-shell/kubectl-shell.js deleted file mode 100644 index a4e45d1fd..000000000 --- a/app/kubernetes/components/kubectl-shell/kubectl-shell.js +++ /dev/null @@ -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, -}); diff --git a/app/kubernetes/components/kubernetes-sidebar/index.js b/app/kubernetes/components/kubernetes-sidebar/index.js deleted file mode 100644 index 570af1f59..000000000 --- a/app/kubernetes/components/kubernetes-sidebar/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import angular from 'angular'; - -angular.module('portainer.kubernetes').component('kubernetesSidebar', { - templateUrl: './kubernetes-sidebar.html', - bindings: { - endpointId: '<', - isSidebarOpen: '<', - adminAccess: '<', - }, -}); diff --git a/app/kubernetes/components/kubernetes-sidebar/kubernetes-sidebar.html b/app/kubernetes/components/kubernetes-sidebar/kubernetes-sidebar.html deleted file mode 100644 index 04d2fd52c..000000000 --- a/app/kubernetes/components/kubernetes-sidebar/kubernetes-sidebar.html +++ /dev/null @@ -1,120 +0,0 @@ - - Dashboard - - - - Custom Templates - - - - Namespaces - - - - Helm - - - - Applications - - - - ConfigMaps & Secrets - - - - Volumes - - - - - Setup - - - - Security constraints - - - - Registries - - diff --git a/app/kubernetes/services/kubeconfig.service.ts b/app/kubernetes/services/kubeconfig.service.ts index 92e8cfd99..72ccb6c5a 100644 --- a/app/kubernetes/services/kubeconfig.service.ts +++ b/app/kubernetes/services/kubeconfig.service.ts @@ -2,7 +2,6 @@ import { saveAs } from 'file-saver'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/portainer/environments/types'; -import { publicSettings } from '@/portainer/settings/settings.service'; const baseUrl = 'kubernetes'; @@ -22,22 +21,3 @@ export async function downloadKubeconfigFile(environmentIds: EnvironmentId[]) { 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.`; - } -} diff --git a/app/portainer/__module.js b/app/portainer/__module.js index b17ccf874..009d7ffa1 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -10,6 +10,7 @@ import teamsModule from './teams'; import homeModule from './home'; import { accessControlModule } from './access-control'; import { reactModule } from './react'; +import { sidebarModule } from './react/views/sidebar'; async function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); @@ -40,6 +41,7 @@ angular teamsModule, accessControlModule, reactModule, + sidebarModule, ]) .config([ '$stateRegistryProvider', @@ -68,8 +70,7 @@ angular }, views: { 'sidebar@': { - templateUrl: './views/sidebar/sidebar.html', - controller: 'SidebarController', + component: 'sidebar', }, }, }; diff --git a/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx b/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx index 5c9fed378..790754cd0 100644 --- a/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx +++ b/app/portainer/access-control/AccessControlPanel/AccessControlPanel.tsx @@ -106,9 +106,9 @@ export function AccessControlPanel({ function useRestrictions(resourceControl?: ResourceControlViewModel) { const { user, isAdmin } = useUser(); - const memberships = useUserMembership(user?.Id); + const memberships = useUserMembership(user.Id); - if (!resourceControl || isAdmin || !user) { + if (!resourceControl || isAdmin) { return { isPartOfRestrictedUsers: false, isLeaderOfAnyRestrictedTeams: false, diff --git a/app/portainer/components/index.js b/app/portainer/components/index.js index cf75413c2..ed2840058 100644 --- a/app/portainer/components/index.js +++ b/app/portainer/components/index.js @@ -1,7 +1,6 @@ import angular from 'angular'; import formComponentsModule from './form-components'; -import sidebarModule from './sidebar'; import gitFormModule from './forms/git-form'; import porAccessManagementModule from './accessManagement'; import widgetModule from './widget'; @@ -13,7 +12,7 @@ import { beFeatureIndicator } from './BEFeatureIndicator'; import { InformationPanelAngular } from './InformationPanel'; 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('portainerTooltip', TooltipAngular) diff --git a/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.module.css b/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.module.css deleted file mode 100644 index 4f751ad36..000000000 --- a/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.sidebar-menu-item > a { - display: flex; - justify-content: space-between; -} diff --git a/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.stories.tsx b/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.stories.tsx deleted file mode 100644 index aae4d9843..000000000 --- a/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.stories.tsx +++ /dev/null @@ -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 ( - - ); -} - -export const Primary: Story = Template.bind({}); -Primary.args = { - iconClass: 'fa-tachometer-alt fa-fw', - className: 'exampleItemClass', - itemName: 'ExampleItem', - linkName: 'Item with icon', -}; - -export const WithoutIcon: Story = Template.bind({}); -WithoutIcon.args = { - className: 'exampleItemClass', - itemName: 'ExampleItem', - linkName: 'Item without icon', -}; diff --git a/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.test.tsx b/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.test.tsx deleted file mode 100644 index febee07bf..000000000 --- a/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.test.tsx +++ /dev/null @@ -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( - - {linkText} - - ); -} diff --git a/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.tsx b/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.tsx deleted file mode 100644 index f0269c267..000000000 --- a/app/portainer/components/sidebar/SidebarMenuItem/SidebarMenuItem.tsx +++ /dev/null @@ -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) { - return ( -
  • - - - {children} - {iconClass && ( - - )} - - -
  • - ); -} diff --git a/app/portainer/components/sidebar/SidebarMenuItem/index.ts b/app/portainer/components/sidebar/SidebarMenuItem/index.ts deleted file mode 100644 index a8bb88d26..000000000 --- a/app/portainer/components/sidebar/SidebarMenuItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SidebarMenuItem } from './SidebarMenuItem'; diff --git a/app/portainer/components/sidebar/index.js b/app/portainer/components/sidebar/index.js deleted file mode 100644 index 1d2c18062..000000000 --- a/app/portainer/components/sidebar/index.js +++ /dev/null @@ -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; diff --git a/app/portainer/components/sidebar/sidebar-menu-item/index.js b/app/portainer/components/sidebar/sidebar-menu-item/index.js deleted file mode 100644 index 014d6262f..000000000 --- a/app/portainer/components/sidebar/sidebar-menu-item/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import './sidebar-menu-item.css'; - -export const sidebarMenuItem = { - templateUrl: './sidebar-menu-item.html', - bindings: { - path: '@', - pathParams: '<', - iconClass: '@', - className: '@', - }, - transclude: true, -}; diff --git a/app/portainer/components/sidebar/sidebar-menu-item/sidebar-menu-item.css b/app/portainer/components/sidebar/sidebar-menu-item/sidebar-menu-item.css deleted file mode 100644 index 4f751ad36..000000000 --- a/app/portainer/components/sidebar/sidebar-menu-item/sidebar-menu-item.css +++ /dev/null @@ -1,4 +0,0 @@ -.sidebar-menu-item > a { - display: flex; - justify-content: space-between; -} diff --git a/app/portainer/components/sidebar/sidebar-menu-item/sidebar-menu-item.html b/app/portainer/components/sidebar/sidebar-menu-item/sidebar-menu-item.html deleted file mode 100644 index bfcb509c9..000000000 --- a/app/portainer/components/sidebar/sidebar-menu-item/sidebar-menu-item.html +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/app/portainer/components/sidebar/sidebar-menu/index.js b/app/portainer/components/sidebar/sidebar-menu/index.js deleted file mode 100644 index 8c9d24aea..000000000 --- a/app/portainer/components/sidebar/sidebar-menu/index.js +++ /dev/null @@ -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, -}; diff --git a/app/portainer/components/sidebar/sidebar-menu/sidebar-menu.controller.js b/app/portainer/components/sidebar/sidebar-menu/sidebar-menu.controller.js deleted file mode 100644 index 8122f7ea9..000000000 --- a/app/portainer/components/sidebar/sidebar-menu/sidebar-menu.controller.js +++ /dev/null @@ -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; diff --git a/app/portainer/components/sidebar/sidebar-menu/sidebar-menu.css b/app/portainer/components/sidebar/sidebar-menu/sidebar-menu.css deleted file mode 100644 index f28c4ae14..000000000 --- a/app/portainer/components/sidebar/sidebar-menu/sidebar-menu.css +++ /dev/null @@ -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; -} diff --git a/app/portainer/components/sidebar/sidebar-menu/sidebar-menu.html b/app/portainer/components/sidebar/sidebar-menu/sidebar-menu.html deleted file mode 100644 index 2105a917e..000000000 --- a/app/portainer/components/sidebar/sidebar-menu/sidebar-menu.html +++ /dev/null @@ -1,20 +0,0 @@ - diff --git a/app/portainer/components/sidebar/sidebar-section/index.js b/app/portainer/components/sidebar/sidebar-section/index.js deleted file mode 100644 index b577b6660..000000000 --- a/app/portainer/components/sidebar/sidebar-section/index.js +++ /dev/null @@ -1,7 +0,0 @@ -export const sidebarSection = { - templateUrl: './sidebar-section.html', - transclude: true, - bindings: { - title: '@', - }, -}; diff --git a/app/portainer/components/sidebar/sidebar-section/sidebar-section.html b/app/portainer/components/sidebar/sidebar-section/sidebar-section.html deleted file mode 100644 index 057db83f8..000000000 --- a/app/portainer/components/sidebar/sidebar-section/sidebar-section.html +++ /dev/null @@ -1,7 +0,0 @@ - diff --git a/app/portainer/components/sidebar/sidebar.css b/app/portainer/components/sidebar/sidebar.css deleted file mode 100644 index 5f75c90dd..000000000 --- a/app/portainer/components/sidebar/sidebar.css +++ /dev/null @@ -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; - } -} diff --git a/app/portainer/environments/environment.service/index.ts b/app/portainer/environments/environment.service/index.ts index 86ed346c9..6db67d0d1 100644 --- a/app/portainer/environments/environment.service/index.ts +++ b/app/portainer/environments/environment.service/index.ts @@ -8,7 +8,7 @@ import type { Environment, EnvironmentId, EnvironmentType, - EnvironmentSettings, + EnvironmentSecuritySettings, EnvironmentStatus, } from '../types'; @@ -239,7 +239,7 @@ export async function forceUpdateService( export async function updateSettings( id: EnvironmentId, - settings: EnvironmentSettings + settings: EnvironmentSecuritySettings ) { try { await axios.put(buildUrl(id, 'settings'), settings); @@ -247,14 +247,3 @@ export async function updateSettings( throw parseAxiosError(e as Error); } } - -export async function trustEndpoint(id: EnvironmentId) { - try { - const { data: endpoint } = await axios.put(buildUrl(id), { - UserTrusted: true, - }); - return endpoint; - } catch (e) { - throw parseAxiosError(e as Error, 'Unable to update environment'); - } -} diff --git a/app/portainer/environments/queries/useEnvironment.ts b/app/portainer/environments/queries/useEnvironment.ts new file mode 100644 index 000000000..80610ac2f --- /dev/null +++ b/app/portainer/environments/queries/useEnvironment.ts @@ -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, + }); +} diff --git a/app/portainer/environments/types.ts b/app/portainer/environments/types.ts index b60e19d8a..b47dad54e 100644 --- a/app/portainer/environments/types.ts +++ b/app/portainer/environments/types.ts @@ -61,6 +61,27 @@ export type EnvironmentEdge = { 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 = { Id: EnvironmentId; Type: EnvironmentType; @@ -81,6 +102,7 @@ export type Environment = { UserTrusted: boolean; AMTDeviceGUID?: string; Edge: EnvironmentEdge; + SecuritySettings: EnvironmentSecuritySettings; }; /** * TS reference of endpoint_create.go#EndpointCreationType iota @@ -99,24 +121,3 @@ export enum PlatformType { Kubernetes, 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; -} diff --git a/app/portainer/environments/utils.ts b/app/portainer/environments/utils.ts index 3fab12f89..50efaaee5 100644 --- a/app/portainer/environments/utils.ts +++ b/app/portainer/environments/utils.ts @@ -13,7 +13,7 @@ export function getPlatformType(envType: EnvironmentType) { case EnvironmentType.Azure: return PlatformType.Azure; default: - throw new Error(`Environment Type ${envType} is not supported`); + throw new Error(`${envType} is not a supported environment type`); } } diff --git a/app/portainer/home/EnvironmentList/KubeconfigButton.tsx b/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx similarity index 91% rename from app/portainer/home/EnvironmentList/KubeconfigButton.tsx rename to app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx index 0d62e70e8..40b55fdec 100644 --- a/app/portainer/home/EnvironmentList/KubeconfigButton.tsx +++ b/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigButton.tsx @@ -10,14 +10,11 @@ import { Button } from '@@/buttons'; import { KubeconfigPrompt } from './KubeconfigPrompt'; import '@reach/dialog/styles.css'; -export interface KubeconfigButtonProps { +export interface Props { environments: Environment[]; envQueryParams: EnvironmentsQueryParams; } -export function KubeconfigButton({ - environments, - envQueryParams, -}: KubeconfigButtonProps) { +export function KubeconfigButton({ environments, envQueryParams }: Props) { const [isOpen, setIsOpen] = useState(false); if (!environments) { diff --git a/app/portainer/home/EnvironmentList/KubeconfigPrompt.module.css b/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigPrompt.module.css similarity index 100% rename from app/portainer/home/EnvironmentList/KubeconfigPrompt.module.css rename to app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigPrompt.module.css diff --git a/app/portainer/home/EnvironmentList/KubeconfigPrompt.tsx b/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx similarity index 87% rename from app/portainer/home/EnvironmentList/KubeconfigPrompt.tsx rename to app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx index ab83ebc0f..d26ed7a98 100644 --- a/app/portainer/home/EnvironmentList/KubeconfigPrompt.tsx +++ b/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { useQuery } from 'react-query'; import { DialogOverlay } from '@reach/dialog'; 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 { usePaginationLimitState } from '@/portainer/hooks/usePaginationLimitState'; import { useEnvironmentList } from '@/portainer/environments/queries/useEnvironmentList'; +import { usePublicSettings } from '@/portainer/settings/queries'; import { PaginationControls } from '@@/PaginationControls'; import { Checkbox } from '@@/form-components/Checkbox'; @@ -29,10 +29,11 @@ export function KubeconfigPrompt({ }: KubeconfigPromptProps) { const [page, setPage] = useState(1); const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey); - const kubeServiceExpiryQuery = useQuery(['kubeServiceExpiry'], async () => { - const expiryMessage = await kcService.expiryMessage(); - return expiryMessage; - }); + + const expiryQuery = usePublicSettings((settings) => + expiryMessage(settings.KubeconfigExpiry) + ); + const { selection, toggle: toggleSelection, selectionSize } = useSelection(); const { environments, totalCount } = useEnvironmentList({ ...envQueryParams, @@ -67,9 +68,7 @@ export function KubeconfigPrompt({ Select the kubernetes environments to add to the kubeconfig file. You may select across multiple pages. - - {kubeServiceExpiryQuery.data} - + {expiryQuery.data}
    @@ -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.`; + } +} diff --git a/app/portainer/home/EnvironmentList/KubeconfigSelection.ts b/app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigSelection.ts similarity index 100% rename from app/portainer/home/EnvironmentList/KubeconfigSelection.ts rename to app/portainer/home/EnvironmentList/KubeconfigButton/KubeconfigSelection.ts diff --git a/app/portainer/home/EnvironmentList/KubeconfigButton/index.ts b/app/portainer/home/EnvironmentList/KubeconfigButton/index.ts new file mode 100644 index 000000000..e5e2db4ec --- /dev/null +++ b/app/portainer/home/EnvironmentList/KubeconfigButton/index.ts @@ -0,0 +1 @@ +export { KubeconfigButton } from './KubeconfigButton'; diff --git a/app/portainer/hooks/useUser.tsx b/app/portainer/hooks/useUser.tsx index d1cfedf69..a5da2468b 100644 --- a/app/portainer/hooks/useUser.tsx +++ b/app/portainer/hooks/useUser.tsx @@ -7,10 +7,14 @@ import { useEffect, useState, useMemo, + PropsWithChildren, } from 'react'; +import { isAdmin } from '@/portainer/users/user.helpers'; + import { getUser } from '../users/user.service'; import { User, UserId } from '../users/types'; +import { EnvironmentId } from '../environments/types'; import { useLocalStorage } from './useLocalStorage'; @@ -27,9 +31,17 @@ export function useUser() { throw new Error('should be nested under UserProvider'); } + const { user } = context; + if (typeof user === 'undefined') { + throw new Error('should be authenticated'); + } + 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[], adminOnlyCE = false ) { - const authorizationsArray = - typeof authorizations === 'string' ? [authorizations] : authorizations; - const { user } = useUser(); - const { params } = useCurrentStateAndParams(); + const { + params: { endpointId }, + } = useCurrentStateAndParams(); if (!user) { 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') { return !adminOnlyCE || isAdmin(user); } - const { endpointId } = params; - if (!endpointId) { + if (!environmentId) { return false; } @@ -62,12 +101,12 @@ export function useAuthorizations( if ( !user.EndpointAuthorizations || - !user.EndpointAuthorizations[endpointId] + !user.EndpointAuthorizations[environmentId] ) { return false; } - const userEndpointAuthorizations = user.EndpointAuthorizations[endpointId]; + const userEndpointAuthorizations = user.EndpointAuthorizations[environmentId]; return authorizationsArray.some( (authorization) => userEndpointAuthorizations[authorization] ); @@ -75,11 +114,15 @@ export function useAuthorizations( interface AuthorizedProps { authorizations: string | string[]; - children: ReactNode; + adminOnlyCE?: boolean; } -export function Authorized({ authorizations, children }: AuthorizedProps) { - const isAllowed = useAuthorizations(authorizations); +export function Authorized({ + authorizations, + adminOnlyCE = false, + children, +}: PropsWithChildren) { + const isAllowed = useAuthorizations(authorizations, adminOnlyCE); return isAllowed ? <>{children} : null; } @@ -90,7 +133,7 @@ interface UserProviderProps { export function UserProvider({ children }: UserProviderProps) { const [jwt] = useLocalStorage('JWT', ''); - const [user, setUser] = useState(); + const [user, setUser] = useState(); useEffect(() => { if (jwt !== '') { @@ -106,7 +149,7 @@ export function UserProvider({ children }: UserProviderProps) { return null; } - if (providerState.user === null) { + if (!providerState.user) { return null; } @@ -122,10 +165,6 @@ export function UserProvider({ children }: UserProviderProps) { } } -function isAdmin(user?: User): boolean { - return !!user && user.Role === 1; -} - export function useIsAdmin() { const { user } = useUser(); return !!user && isAdmin(user); diff --git a/app/portainer/react/views/sidebar.ts b/app/portainer/react/views/sidebar.ts new file mode 100644 index 000000000..52069b090 --- /dev/null +++ b/app/portainer/react/views/sidebar.ts @@ -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; diff --git a/app/portainer/services/api/status.service.ts b/app/portainer/services/api/status.service.ts index a33d17b87..f18599122 100644 --- a/app/portainer/services/api/status.service.ts +++ b/app/portainer/services/api/status.service.ts @@ -2,7 +2,7 @@ import { useQuery } from 'react-query'; import axios, { parseAxiosError } from '../axios'; -interface NodesCountResponse { +export interface NodesCountResponse { nodes: number; } @@ -16,6 +16,7 @@ export async function getNodesCount() { } export interface StatusResponse { + Edition: string; Version: string; InstanceID: string; } @@ -24,6 +25,10 @@ export async function getStatus() { try { const { data } = await axios.get(buildUrl()); + if (process.env.PORTAINER_EDITION !== 'CE') { + data.Edition = 'Business Edition'; + } + return data; } catch (error) { throw parseAxiosError(error as Error); @@ -36,6 +41,22 @@ export function useStatus( 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(buildUrl('version')); + return data; + } catch (error) { + throw parseAxiosError(error as Error); + } +} + function buildUrl(action?: string) { let url = '/status'; diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js index 558201942..7fcafac10 100644 --- a/app/portainer/services/localStorage.js +++ b/app/portainer/services/localStorage.js @@ -126,12 +126,6 @@ angular.module('portainer.app').factory('LocalStorage', [ const activeTab = localStorageService.get('active_tab_' + key); 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), getLogoutReason: () => localStorageService.get('logout_reason'), cleanLogoutReason: () => localStorageService.remove('logout_reason'), diff --git a/app/portainer/services/modal.service/index.ts b/app/portainer/services/modal.service/index.ts index 0c10342f4..19012110a 100644 --- a/app/portainer/services/modal.service/index.ts +++ b/app/portainer/services/modal.service/index.ts @@ -22,7 +22,6 @@ import { confirmContainerRecreation, confirmServiceForceUpdate, confirmStackUpdate, - confirmKubeconfigSelection, selectRegistry, } from './prompt'; @@ -58,7 +57,6 @@ export function ModalServiceAngular() { confirmStackUpdate, selectRegistry, confirmContainerDeletion, - confirmKubeconfigSelection, confirmForceChangePassword, }; } diff --git a/app/portainer/services/modal.service/prompt.ts b/app/portainer/services/modal.service/prompt.ts index cc597dfd8..40af87b78 100644 --- a/app/portainer/services/modal.service/prompt.ts +++ b/app/portainer/services/modal.service/prompt.ts @@ -177,30 +177,6 @@ export function confirmStackUpdate( 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.
    ${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( box: JQuery, message: string, diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index 27a08479c..e878e074a 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -29,11 +29,6 @@ function StateManagerFactory( }, }; - manager.setVersionInfo = function (versionInfo) { - state.application.versionStatus = versionInfo; - LocalStorage.storeApplicationState(state.application); - }; - manager.dismissInformationPanel = function (id) { state.UI.dismissedInfoPanels[id] = true; LocalStorage.storeUIState(state.UI); diff --git a/app/portainer/services/terminal-window.js b/app/portainer/services/terminal-window.js deleted file mode 100644 index 5bd10dbf7..000000000 --- a/app/portainer/services/terminal-window.js +++ /dev/null @@ -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'; - }; -}); diff --git a/app/portainer/services/terminal-window.ts b/app/portainer/services/terminal-window.ts new file mode 100644 index 000000000..ffbaffbc6 --- /dev/null +++ b/app/portainer/services/terminal-window.ts @@ -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; + } +} diff --git a/app/portainer/settings/edge-compute/EdgeComputeSettings/EdgeComputeSettings.tsx b/app/portainer/settings/edge-compute/EdgeComputeSettings/EdgeComputeSettings.tsx index ebe09cd6a..6ca15b7ba 100644 --- a/app/portainer/settings/edge-compute/EdgeComputeSettings/EdgeComputeSettings.tsx +++ b/app/portainer/settings/edge-compute/EdgeComputeSettings/EdgeComputeSettings.tsx @@ -111,7 +111,7 @@ export function EdgeComputeSettings({ settings, onSubmit }: Props) {
    diff --git a/app/portainer/settings/edge-compute/SettingsFDO/SettingsFDO.tsx b/app/portainer/settings/edge-compute/SettingsFDO/SettingsFDO.tsx index b01ced725..952b496d7 100644 --- a/app/portainer/settings/edge-compute/SettingsFDO/SettingsFDO.tsx +++ b/app/portainer/settings/edge-compute/SettingsFDO/SettingsFDO.tsx @@ -145,7 +145,7 @@ export function SettingsFDO({ settings, onSubmit }: Props) {
    diff --git a/app/portainer/settings/edge-compute/SettingsOpenAMT/SettingsOpenAMT.tsx b/app/portainer/settings/edge-compute/SettingsOpenAMT/SettingsOpenAMT.tsx index 4b2c06ba4..891a168d5 100644 --- a/app/portainer/settings/edge-compute/SettingsOpenAMT/SettingsOpenAMT.tsx +++ b/app/portainer/settings/edge-compute/SettingsOpenAMT/SettingsOpenAMT.tsx @@ -248,7 +248,7 @@ export function SettingsOpenAMT({ settings, onSubmit }: Props) {
    diff --git a/app/portainer/settings/queries.ts b/app/portainer/settings/queries.ts index c7c9b5d4c..e23d8693b 100644 --- a/app/portainer/settings/queries.ts +++ b/app/portainer/settings/queries.ts @@ -1,19 +1,26 @@ 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 { - publicSettings, getSettings, updateSettings, + getPublicSettings, } from './settings.service'; import { Settings } from './types'; -export function usePublicSettings() { - return useQuery(['settings', 'public'], () => publicSettings(), { - onError: (err) => { - notifyError('Failure', err as Error, 'Unable to retrieve settings'); - }, +export function usePublicSettings( + select?: (settings: PublicSettingsViewModel) => T +) { + return useQuery(['settings', 'public'], () => getPublicSettings(), { + select, + ...withError('Unable to retrieve public settings'), }); } @@ -24,27 +31,18 @@ export function useSettings( return useQuery(['settings'], getSettings, { select, enabled, - meta: { - error: { - title: 'Failure', - message: 'Unable to retrieve settings', - }, - }, + ...withError('Unable to retrieve settings'), }); } export function useUpdateSettingsMutation() { const queryClient = useQueryClient(); - return useMutation(updateSettings, { - onSuccess() { - return queryClient.invalidateQueries(['settings']); - }, - meta: { - error: { - title: 'Failure', - message: 'Unable to update settings', - }, - }, - }); + return useMutation( + updateSettings, + mutationOptions( + withInvalidate(queryClient, [['settings'], ['cloud']]), + withError('Unable to update settings') + ) + ); } diff --git a/app/portainer/settings/settings.service.ts b/app/portainer/settings/settings.service.ts index e2dd857b3..a266d8a2d 100644 --- a/app/portainer/settings/settings.service.ts +++ b/app/portainer/settings/settings.service.ts @@ -2,11 +2,13 @@ import { PublicSettingsViewModel } from '@/portainer/models/settings'; import axios, { parseAxiosError } from '../services/axios'; -import { Settings } from './types'; +import { PublicSettingsResponse, Settings } from './types'; -export async function publicSettings() { +export async function getPublicSettings() { try { - const { data } = await axios.get(buildUrl('public')); + const { data } = await axios.get( + buildUrl('public') + ); return new PublicSettingsViewModel(data); } catch (e) { throw parseAxiosError( diff --git a/app/portainer/settings/types.ts b/app/portainer/settings/types.ts index b5ed6dc26..3616eb802 100644 --- a/app/portainer/settings/types.ts +++ b/app/portainer/settings/types.ts @@ -132,3 +132,24 @@ export interface Settings { 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; + // 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; +} diff --git a/app/portainer/teams/CreateTeamForm/CreateTeamForm.tsx b/app/portainer/teams/CreateTeamForm/CreateTeamForm.tsx index 9089fc38c..c17d01e83 100644 --- a/app/portainer/teams/CreateTeamForm/CreateTeamForm.tsx +++ b/app/portainer/teams/CreateTeamForm/CreateTeamForm.tsx @@ -92,7 +92,7 @@ export function CreateTeamForm({ users, teams, onSubmit }: Props) {
    diff --git a/app/portainer/users/queries.ts b/app/portainer/users/queries.ts index 64bdc3ec4..246d61e91 100644 --- a/app/portainer/users/queries.ts +++ b/app/portainer/users/queries.ts @@ -1,16 +1,37 @@ import { useQuery } from 'react-query'; +import { Role as TeamRole, TeamMembership } from '../teams/types'; + import { User, UserId } from './types'; +import { isAdmin } from './user.helpers'; import { getUserMemberships, getUsers } from './user.service'; -export function useUserMembership(userId?: UserId) { +interface UseUserMembershipOptions { + select?(userMemberships: TeamMembership[]): TSelect; + enabled?: boolean; +} + +export function useUserMembership( + userId: UserId, + { enabled, select }: UseUserMembershipOptions = {} +) { return useQuery( ['users', userId, 'memberships'], () => 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( includeAdministrator: boolean, enabled = true, diff --git a/app/portainer/users/user.helpers.ts b/app/portainer/users/user.helpers.ts index 5731cbad4..82f829975 100644 --- a/app/portainer/users/user.helpers.ts +++ b/app/portainer/users/user.helpers.ts @@ -3,3 +3,7 @@ import { Role, User } from './types'; export function filterNonAdministratorUsers(users: User[]) { return users.filter((user) => user.Role !== Role.Admin); } + +export function isAdmin(user?: User): boolean { + return !!user && user.Role === 1; +} diff --git a/app/portainer/users/user.service.ts b/app/portainer/users/user.service.ts index cac347e09..7a7aba283 100644 --- a/app/portainer/users/user.service.ts +++ b/app/portainer/users/user.service.ts @@ -25,12 +25,8 @@ export async function getUser(id: UserId) { } } -export async function getUserMemberships(id?: UserId) { +export async function getUserMemberships(id: UserId) { try { - if (!id) { - throw new Error('missing id'); - } - const { data } = await axios.get( buildUrl(id, 'memberships') ); diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 86905693e..03e8e3a02 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -50,7 +50,6 @@ class AuthenticationController { }; this.checkForEndpointsAsync = this.checkForEndpointsAsync.bind(this); - this.checkForLatestVersionAsync = this.checkForLatestVersionAsync.bind(this); this.postLoginSteps = this.postLoginSteps.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() { await this.StateManager.initialize(); @@ -161,7 +143,6 @@ class AuthenticationController { this.$analytics.setUserRole(isAdmin ? 'admin' : 'standard-user'); await this.checkForEndpointsAsync(); - await this.checkForLatestVersionAsync(); } /** * END POST LOGIN STEPS SECTION diff --git a/app/portainer/views/main/mainController.js b/app/portainer/views/main/mainController.js index c4a62895e..355fa9ce6 100644 --- a/app/portainer/views/main/mainController.js +++ b/app/portainer/views/main/mainController.js @@ -1,39 +1,14 @@ -angular.module('portainer.app').controller('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; - }; +angular.module('portainer.app').controller('MainController', MainController); - $scope.applicationState = StateManager.getState(); - $scope.endpointState = EndpointProvider.endpoint(); +/* @ngInject */ +function MainController($scope, StateManager, ThemeManager, SidebarService) { + /** + * Sidebar Toggle & Cookie Control + */ - $scope.$watch($scope.getWidth, function (newValue) { - if (newValue >= mobileView) { - const toggleValue = LocalStorage.getToolbarToggle(); - $scope.toggle = typeof toggleValue === 'boolean' ? toggleValue : !window.ddExtension; - } else { - $scope.toggle = false; - } - }); + $scope.applicationState = StateManager.getState(); - $scope.toggleSidebar = function () { - $scope.toggle = !$scope.toggle; - LocalStorage.storeToolbarToggle($scope.toggle); - }; + $scope.isSidebarOpen = SidebarService.isSidebarOpen; - window.onresize = function () { - $scope.$apply(); - }; - - ThemeManager.autoTheme(); - }, -]); + ThemeManager.autoTheme(); +} diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html deleted file mode 100644 index dd033b672..000000000 --- a/app/portainer/views/sidebar/sidebar.html +++ /dev/null @@ -1,131 +0,0 @@ - - - diff --git a/app/portainer/views/sidebar/sidebarController.js b/app/portainer/views/sidebar/sidebarController.js deleted file mode 100644 index f6e18293a..000000000 --- a/app/portainer/views/sidebar/sidebarController.js +++ /dev/null @@ -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}`; - } - }); -} diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts index afb2d04ab..4435b2e0b 100644 --- a/app/react-tools/test-mocks.ts +++ b/app/react-tools/test-mocks.ts @@ -80,5 +80,16 @@ export function createMockEnvironment(): Environment { CommandInterval: 0, SnapshotInterval: 0, }, + SecuritySettings: { + allowBindMountsForRegularUsers: false, + allowPrivilegedModeForRegularUsers: false, + allowContainerCapabilitiesForRegularUsers: false, + allowDeviceMappingForRegularUsers: false, + allowHostNamespaceForRegularUsers: false, + allowStackManagementForRegularUsers: false, + allowSysctlSettingForRegularUsers: false, + allowVolumeBrowserForRegularUsers: false, + enableHostManagementFeatures: false, + }, }; } diff --git a/app/react/components/PageHeader/HeaderContainer.css b/app/react/components/PageHeader/HeaderContainer.css index 921c89b7c..d5a58e20d 100644 --- a/app/react/components/PageHeader/HeaderContainer.css +++ b/app/react/components/PageHeader/HeaderContainer.css @@ -2,10 +2,6 @@ padding-top: 7px; } -body.hamburg .row.header .meta { - margin-left: 70px; -} - .row.header { min-height: 60px; background: var(--bg-row-header-color); diff --git a/app/react/components/buttons/Button.tsx b/app/react/components/buttons/Button.tsx index eda63375e..9a762b517 100644 --- a/app/react/components/buttons/Button.tsx +++ b/app/react/components/buttons/Button.tsx @@ -1,6 +1,8 @@ -import { MouseEventHandler, PropsWithChildren } from 'react'; +import { AriaAttributes, MouseEventHandler, PropsWithChildren } from 'react'; import clsx from 'clsx'; +import { AutomationTestingProps } from '@/types'; + type Type = 'submit' | 'button' | 'reset'; type Color = | 'default' @@ -13,13 +15,12 @@ type Color = | 'dangerlight'; type Size = 'xsmall' | 'small' | 'medium' | 'large'; -export interface Props { +export interface Props extends AriaAttributes, AutomationTestingProps { color?: Color; size?: Size; disabled?: boolean; title?: string; className?: string; - dataCy?: string; type?: Type; onClick?: MouseEventHandler; } @@ -30,20 +31,21 @@ export function Button({ size = 'small', disabled = false, className, - dataCy, onClick, title, children, + ...ariaProps }: PropsWithChildren) { return ( diff --git a/app/azure/AzureSidebar/AzureSidebar.test.tsx b/app/react/sidebar/AzureSidebar/AzureSidebar.test.tsx similarity index 73% rename from app/azure/AzureSidebar/AzureSidebar.test.tsx rename to app/react/sidebar/AzureSidebar/AzureSidebar.test.tsx index 607e6663b..90f903ba7 100644 --- a/app/azure/AzureSidebar/AzureSidebar.test.tsx +++ b/app/react/sidebar/AzureSidebar/AzureSidebar.test.tsx @@ -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 { AzureSidebar } from './AzureSidebar'; @@ -15,7 +17,7 @@ test('dashboard items should render correctly', () => { 'fa-fw' ); - const containerInstancesItem = getByLabelText('ContainerInstances'); + const containerInstancesItem = getByLabelText(/Container Instances/i); expect(containerInstancesItem).toBeVisible(); expect(containerInstancesItem).toHaveTextContent('Container instances'); @@ -30,5 +32,11 @@ test('dashboard items should render correctly', () => { }); function renderComponent() { - return render(); + const user = new UserViewModel({ Username: 'user' }); + + return render( + + + + ); } diff --git a/app/react/sidebar/AzureSidebar/AzureSidebar.tsx b/app/react/sidebar/AzureSidebar/AzureSidebar.tsx new file mode 100644 index 000000000..7c2130a1f --- /dev/null +++ b/app/react/sidebar/AzureSidebar/AzureSidebar.tsx @@ -0,0 +1,26 @@ +import { EnvironmentId } from '@/portainer/environments/types'; + +import { SidebarItem } from '../SidebarItem'; + +interface Props { + environmentId: EnvironmentId; +} + +export function AzureSidebar({ environmentId }: Props) { + return ( + <> + + + + ); +} diff --git a/app/react/sidebar/AzureSidebar/index.ts b/app/react/sidebar/AzureSidebar/index.ts new file mode 100644 index 000000000..6348b5659 --- /dev/null +++ b/app/react/sidebar/AzureSidebar/index.ts @@ -0,0 +1 @@ +export { AzureSidebar } from './AzureSidebar'; diff --git a/app/react/sidebar/DockerSidebar.tsx b/app/react/sidebar/DockerSidebar.tsx new file mode 100644 index 000000000..53581f266 --- /dev/null +++ b/app/react/sidebar/DockerSidebar.tsx @@ -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 ( + <> + + + {!offlineMode && ( + + + + )} + + {areStacksVisible && ( + + )} + + {isSwarmManager && ( + + )} + + + + + + + + + + {apiVersion >= 1.3 && isSwarmManager && ( + + )} + + {apiVersion >= 1.25 && isSwarmManager && ( + + )} + + {!isSwarmManager && isAdmin && !offlineMode && ( + + )} + + + + + + + + + + ); +} diff --git a/app/react/sidebar/EdgeComputeSidebar.tsx b/app/react/sidebar/EdgeComputeSidebar.tsx new file mode 100644 index 000000000..7b7427533 --- /dev/null +++ b/app/react/sidebar/EdgeComputeSidebar.tsx @@ -0,0 +1,29 @@ +import { SidebarItem } from './SidebarItem'; +import { SidebarSection } from './SidebarSection'; + +export function EdgeComputeSidebar() { + return ( + + + + + + + ); +} diff --git a/app/react/sidebar/EnvironmentSidebar.module.css b/app/react/sidebar/EnvironmentSidebar.module.css new file mode 100644 index 000000000..079d3471c --- /dev/null +++ b/app/react/sidebar/EnvironmentSidebar.module.css @@ -0,0 +1,5 @@ +.title { + color: #fff; + text-align: center; + text-indent: 0; +} diff --git a/app/react/sidebar/EnvironmentSidebar.tsx b/app/react/sidebar/EnvironmentSidebar.tsx new file mode 100644 index 000000000..4f4be31c6 --- /dev/null +++ b/app/react/sidebar/EnvironmentSidebar.tsx @@ -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 ( + + + {environment.Name} +
    + } + label={PlatformType[platform]} + > + {sidebar} + + ); + + function getSidebar(environment: Environment) { + switch (platform) { + case PlatformType.Azure: + return ; + case PlatformType.Docker: + return ( + + ); + case PlatformType.Kubernetes: + return ; + default: + return null; + } + } +} + +function useCurrentEnvironment() { + const { params } = useCurrentStateAndParams(); + + const [environmentId, setEnvironmentId] = useState(); + + useEffect(() => { + const environmentId = parseInt(params.endpointId, 10); + if (params.endpointId && !Number.isNaN(environmentId)) { + setEnvironmentId(environmentId); + } + }, [params.endpointId]); + + return useEnvironment(environmentId); +} diff --git a/app/react/sidebar/Footer.module.css b/app/react/sidebar/Footer.module.css new file mode 100644 index 000000000..a5884bc35 --- /dev/null +++ b/app/react/sidebar/Footer.module.css @@ -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; +} diff --git a/app/react/sidebar/Footer.tsx b/app/react/sidebar/Footer.tsx new file mode 100644 index 000000000..08d451ddb --- /dev/null +++ b/app/react/sidebar/Footer.tsx @@ -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 ( +
    + {process.env.PORTAINER_EDITION === 'CE' && } +
    + Portainer + + {Version} + + {process.env.PORTAINER_EDITION !== 'CE' && ( +
    {Edition}
    + )} +
    +
    + ); +} + +function useStatus() { + return useQuery(['status'], () => getStatus()); +} diff --git a/app/react/sidebar/Header.module.css b/app/react/sidebar/Header.module.css new file mode 100644 index 000000000..645e4a811 --- /dev/null +++ b/app/react/sidebar/Header.module.css @@ -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; +} diff --git a/app/react/sidebar/Header.tsx b/app/react/sidebar/Header.tsx new file mode 100644 index 000000000..3f520d98f --- /dev/null +++ b/app/react/sidebar/Header.tsx @@ -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 ( +
    + + {!logo + + {toggle && ( + + )} +
    + ); +} diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.module.css b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.module.css new file mode 100644 index 000000000..8dff3707b --- /dev/null +++ b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.module.css @@ -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; +} diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.tsx b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.tsx new file mode 100644 index 000000000..8612f60c6 --- /dev/null +++ b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShell.tsx @@ -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({ + 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 ( +
    +
    +
    + + kubectl shell +
    +
    + + + +
    +
    + +
    +
    Loading Terminal...
    +
    +
    + ); + + 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}`; + } +} diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.module.css b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.module.css new file mode 100644 index 000000000..ed061a1ad --- /dev/null +++ b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.module.css @@ -0,0 +1,5 @@ +.root { + display: block; + margin: 0 auto; + padding-bottom: 5px; +} diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx new file mode 100644 index 000000000..2d9402b1b --- /dev/null +++ b/app/react/sidebar/KubernetesSidebar/KubectlShell/KubectlShellButton.tsx @@ -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 ( + <> + + + {open && + createPortal( + setOpen(false)} + />, + document.body + )} + + ); + + function handleOpen() { + setOpen(true); + + trackEvent('kubernetes-kubectl-shell', { category: 'kubernetes' }); + } +} diff --git a/app/react/sidebar/KubernetesSidebar/KubectlShell/index.ts b/app/react/sidebar/KubernetesSidebar/KubectlShell/index.ts new file mode 100644 index 000000000..541c38e1f --- /dev/null +++ b/app/react/sidebar/KubernetesSidebar/KubectlShell/index.ts @@ -0,0 +1 @@ +export { KubectlShellButton } from './KubectlShellButton'; diff --git a/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx new file mode 100644 index 000000000..f9d3fec3e --- /dev/null +++ b/app/react/sidebar/KubernetesSidebar/KubernetesSidebar.tsx @@ -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 ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/app/react/sidebar/KubernetesSidebar/index.ts b/app/react/sidebar/KubernetesSidebar/index.ts new file mode 100644 index 000000000..deb2a2a59 --- /dev/null +++ b/app/react/sidebar/KubernetesSidebar/index.ts @@ -0,0 +1 @@ +export { KubernetesSidebar } from './KubernetesSidebar'; diff --git a/app/react/sidebar/SettingsSidebar.tsx b/app/react/sidebar/SettingsSidebar.tsx new file mode 100644 index 000000000..99832b9e5 --- /dev/null +++ b/app/react/sidebar/SettingsSidebar.tsx @@ -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( + (settings) => settings.TeamSync + ); + + const showUsersSection = + !window.ddExtension && (isAdmin || teamSyncQuery.data); + + return ( + + {showUsersSection && ( + + + + {isAdmin && } + + )} + {isAdmin && ( + <> + + + + + + + + + + + + + {!window.ddExtension && ( + + )} + + + + + + + Help / About + + + + + )} + + ); +} diff --git a/app/react/sidebar/Sidebar.module.css b/app/react/sidebar/Sidebar.module.css new file mode 100644 index 000000000..bed86e1ee --- /dev/null +++ b/app/react/sidebar/Sidebar.module.css @@ -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%; +} diff --git a/app/react/sidebar/Sidebar.tsx b/app/react/sidebar/Sidebar.tsx new file mode 100644 index 000000000..192b1e307 --- /dev/null +++ b/app/react/sidebar/Sidebar.tsx @@ -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 */ + + + + ); +} diff --git a/app/react/sidebar/SidebarItem/Icon.module.css b/app/react/sidebar/SidebarItem/Icon.module.css new file mode 100644 index 000000000..242fbfe17 --- /dev/null +++ b/app/react/sidebar/SidebarItem/Icon.module.css @@ -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; + } +} diff --git a/app/react/sidebar/SidebarItem/Icon.tsx b/app/react/sidebar/SidebarItem/Icon.tsx new file mode 100644 index 000000000..649d12704 --- /dev/null +++ b/app/react/sidebar/SidebarItem/Icon.tsx @@ -0,0 +1,18 @@ +import clsx from 'clsx'; + +import styles from './Icon.module.css'; + +interface Props { + iconClass: string; +} + +export function Icon({ iconClass }: Props) { + return ( +