From d032119ebc52a4280015206f20384868d4665a33 Mon Sep 17 00:00:00 2001 From: James Carppe <85850129+jamescarppe@users.noreply.github.com> Date: Fri, 3 Mar 2023 11:38:19 +1300 Subject: [PATCH 01/55] Update docs URL and add --log-level docs (#8511) --- CONTRIBUTING.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37eb771df..ad3e449d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,7 +93,7 @@ $ yarn start Portainer can now be accessed at . -Find more detailed steps at . +Find more detailed steps at . ### Build customisation @@ -103,6 +103,10 @@ You can customise the following settings: - `PORTAINER_PROJECT`: The root dir of the repository - `${portainerRoot}/dist/` is imported into the container to get the build artifacts and external tools (defaults to `your current dir`). - `PORTAINER_FLAGS`: a list of flags to be used on the portainer commandline, in the form `--admin-password= --feat fdo=false --feat open-amt` (default: `""`). +## Testing your build + +The `--log-level=DEBUG` flag can be passed to the Portainer container in order to provide additional debug output which may be useful when troubleshooting your builds. Please note that this flag was originally intended for internal use and as such the format, functionality and output may change between releases without warning. + ## Adding api docs When adding a new resource (or a route handler), we should add a new tag to api/http/handler/handler.go#L136 like this: From 769c8372fbb3859acce6c0c08f7cb6eb94b867a6 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Fri, 3 Mar 2023 12:27:24 +1300 Subject: [PATCH 02/55] feat(microk8s): BE teasers in wizard [EE-4772] (#8449) * feat(microk8s): separate existing/new envs EE-4772 update icons fix be teaser box selector style * match environment wizard box selectors * revert title back * updated kaas description in wizard --------- Co-authored-by: testa113 Co-authored-by: Prabhat Khera --- app/assets/ico/vendor/install-kubernetes.svg | 6 +++ app/assets/ico/vendor/kaas-icon.svg | 4 ++ app/portainer/components/BoxSelector/index.ts | 1 + .../components/BoxSelector/BoxSelector.tsx | 6 +++ .../EndpointTypeView.tsx | 50 ++++++++++++++---- .../EnvironmentSelector.tsx | 43 +++++++--------- .../environment-types.ts | 51 ++++++++++++++++--- .../EnvironmentTypeSelectView/kaas-icon.svg | 4 -- .../EnvironmentsCreationView.tsx | 6 +-- app/react/portainer/feature-flags/enums.ts | 1 + .../feature-flags/feature-flags.service.ts | 1 + 11 files changed, 124 insertions(+), 49 deletions(-) create mode 100644 app/assets/ico/vendor/install-kubernetes.svg create mode 100644 app/assets/ico/vendor/kaas-icon.svg delete mode 100644 app/react/portainer/environments/wizard/EnvironmentTypeSelectView/kaas-icon.svg diff --git a/app/assets/ico/vendor/install-kubernetes.svg b/app/assets/ico/vendor/install-kubernetes.svg new file mode 100644 index 000000000..075065fb6 --- /dev/null +++ b/app/assets/ico/vendor/install-kubernetes.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/assets/ico/vendor/kaas-icon.svg b/app/assets/ico/vendor/kaas-icon.svg new file mode 100644 index 000000000..37d427eed --- /dev/null +++ b/app/assets/ico/vendor/kaas-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/portainer/components/BoxSelector/index.ts b/app/portainer/components/BoxSelector/index.ts index 14115ea3d..95caaa132 100644 --- a/app/portainer/components/BoxSelector/index.ts +++ b/app/portainer/components/BoxSelector/index.ts @@ -14,6 +14,7 @@ const BoxSelectorReact = react2angular(BoxSelector, [ 'options', 'radioName', 'slim', + 'hiddenSpacingCount', ]); export const boxSelectorModule = angular diff --git a/app/react/components/BoxSelector/BoxSelector.tsx b/app/react/components/BoxSelector/BoxSelector.tsx index 624e30649..2a924263d 100644 --- a/app/react/components/BoxSelector/BoxSelector.tsx +++ b/app/react/components/BoxSelector/BoxSelector.tsx @@ -20,12 +20,14 @@ export type Props = Union & { radioName: string; options: ReadonlyArray> | Array>; slim?: boolean; + hiddenSpacingCount?: number; }; export function BoxSelector({ radioName, options, slim = false, + hiddenSpacingCount, ...props }: Props) { return ( @@ -47,6 +49,10 @@ export function BoxSelector({ slim={slim} /> ))} + {hiddenSpacingCount && + Array.from(Array(hiddenSpacingCount)).map((_, index) => ( +
+ ))}
diff --git a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EndpointTypeView.tsx b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EndpointTypeView.tsx index f0fbd9b38..68b0a8cf5 100644 --- a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EndpointTypeView.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/EndpointTypeView.tsx @@ -8,19 +8,21 @@ import { useAnalytics } from '@/angulartics.matomo/analytics-services'; import { Button } from '@@/buttons'; import { PageHeader } from '@@/PageHeader'; import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; +import { FormSection } from '@@/form-components/FormSection'; import { useCreateEdgeDeviceParam } from '../hooks/useCreateEdgeDeviceParam'; +import { EnvironmentSelector } from './EnvironmentSelector'; import { - EnvironmentSelector, - EnvironmentSelectorValue, -} from './EnvironmentSelector'; -import { environmentTypes } from './environment-types'; + EnvironmentOptionValue, + existingEnvironmentTypes, + newEnvironmentTypes, +} from './environment-types'; export function EnvironmentTypeSelectView() { const createEdgeDevice = useCreateEdgeDeviceParam(); - const [types, setTypes] = useState([]); + const [types, setTypes] = useState([]); const { trackEvent } = useAnalytics(); const router = useRouter(); @@ -36,11 +38,34 @@ export function EnvironmentTypeSelectView() { - +
+ +

+ You can onboard different types of environments, select all + that apply. +

+

+ Connect to existing environments +

+ +

Set up new environments

+ +
+
diff --git a/app/portainer/react/components/envronments.ts b/app/portainer/react/components/envronments.ts index 4e7f9ae68..73fa82ef6 100644 --- a/app/portainer/react/components/envronments.ts +++ b/app/portainer/react/components/envronments.ts @@ -4,7 +4,7 @@ import { r2a } from '@/react-tools/react2angular'; import { withControlledInput } from '@/react-tools/withControlledInput'; import { EdgeKeyDisplay } from '@/react/portainer/environments/ItemView/EdgeKeyDisplay'; import { KVMControl } from '@/react/portainer/environments/KvmView/KVMControl'; -import { GpusList } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; +import { GpusList } from '@/react/docker/host/SetupView/GpusList'; export const environmentsModule = angular .module('portainer.app.react.components.environments', []) diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 2bab4d0c8..e949a5013 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -25,6 +25,7 @@ import { Slider } from '@@/form-components/Slider'; import { TagButton } from '@@/TagButton'; import { BETeaserButton } from '@@/BETeaserButton'; import { CodeEditor } from '@@/CodeEditor'; +import { InsightsBox } from '@@/InsightsBox'; import { fileUploadField } from './file-upload-field'; import { switchField } from './switch-field'; @@ -32,6 +33,7 @@ import { customTemplatesModule } from './custom-templates'; import { gitFormModule } from './git-form'; import { settingsModule } from './settings'; import { accessControlModule } from './access-control'; +import { environmentsModule } from './envronments'; import { envListModule } from './enviroments-list-view-components'; export const componentsModule = angular @@ -40,6 +42,7 @@ export const componentsModule = angular gitFormModule, settingsModule, accessControlModule, + environmentsModule, envListModule, ]) .component( @@ -74,6 +77,10 @@ export const componentsModule = angular .component('badge', r2a(Badge, ['type', 'className'])) .component('fileUploadField', fileUploadField) .component('porSwitchField', switchField) + .component( + 'insightsBox', + r2a(InsightsBox, ['header', 'content', 'setHtmlContent', 'insightCloseId']) + ) .component( 'passwordCheckHint', r2a(withReactQuery(PasswordCheckHint), [ diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index 1ffc9b1a1..02f85654d 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -36,7 +36,6 @@ angular.module('portainer.app').factory('Authentication', [ await setUser(jwt); return true; } catch (error) { - console.log('Unable to initialize authentication service', error); return tryAutoLoginExtension(); } } diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index 7d57c1406..e418c4238 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -210,14 +210,30 @@ -
Hardware acceleration
- +
+ +
+ ); +} diff --git a/app/react/components/InsightsBox/index.ts b/app/react/components/InsightsBox/index.ts new file mode 100644 index 000000000..c02dcafa9 --- /dev/null +++ b/app/react/components/InsightsBox/index.ts @@ -0,0 +1 @@ +export { InsightsBox } from './InsightsBox'; diff --git a/app/react/components/InsightsBox/insights-store.ts b/app/react/components/InsightsBox/insights-store.ts new file mode 100644 index 000000000..a35881158 --- /dev/null +++ b/app/react/components/InsightsBox/insights-store.ts @@ -0,0 +1,35 @@ +import { createStore } from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { keyBuilder } from '@/react/hooks/useLocalStorage'; + +interface InsightsStore { + insightIDsClosed: string[]; + addInsightIDClosed: (insightIDClosed: string) => void; + isClosed: (insightID?: string) => boolean; +} + +export const insightStore = createStore()( + persist( + (set, get) => ({ + insightIDsClosed: [], + addInsightIDClosed: (insightIDClosed: string) => { + set((state) => { + const currentIDsClosed = state.insightIDsClosed || []; + return { insightIDsClosed: [...currentIDsClosed, insightIDClosed] }; + }); + }, + isClosed: (insightID?: string) => { + if (!insightID) { + return false; + } + const currentIDsClosed = get().insightIDsClosed || []; + return currentIDsClosed.includes(insightID); + }, + }), + { + name: keyBuilder('insightIDsClosed'), + getStorage: () => localStorage, + } + ) +); diff --git a/app/react/components/form-components/InputList/InputList.module.css b/app/react/components/form-components/InputList/InputList.module.css index b1c67ea6c..fe03fb917 100644 --- a/app/react/components/form-components/InputList/InputList.module.css +++ b/app/react/components/form-components/InputList/InputList.module.css @@ -1,7 +1,3 @@ -.items { - margin-top: 10px; -} - .items > * + * { margin-top: 2px; } @@ -24,11 +20,6 @@ margin-bottom: 20px; } -.item-actions { - display: flex; - margin-left: 2px; -} - .default-item { width: 100% !important; } diff --git a/app/react/components/form-components/InputList/InputList.tsx b/app/react/components/form-components/InputList/InputList.tsx index a2a5f9c04..89540d5d5 100644 --- a/app/react/components/form-components/InputList/InputList.tsx +++ b/app/react/components/form-components/InputList/InputList.tsx @@ -1,9 +1,9 @@ import { ComponentType } from 'react'; import clsx from 'clsx'; import { FormikErrors } from 'formik'; -import { ArrowDown, ArrowUp, Trash2 } from 'lucide-react'; +import { ArrowDown, ArrowUp, Plus, Trash2 } from 'lucide-react'; -import { AddButton, Button } from '@@/buttons'; +import { Button } from '@@/buttons'; import { Tooltip } from '@@/Tip/Tooltip'; import { TextTip } from '@@/Tip/TextTip'; @@ -79,18 +79,10 @@ export function InputList({ return (
-
+ {label} {tooltip && } -
- {!(isAddButtonHidden || readOnly) && ( - - )} +
{textTip && ( @@ -99,68 +91,86 @@ export function InputList({
)} -
- {value.map((item, index) => { - const key = itemKeyGetter(item, index); - const error = typeof errors === 'object' ? errors[index] : undefined; + {value.length > 0 && ( +
+ {value.map((item, index) => { + const key = itemKeyGetter(item, index); + const error = + typeof errors === 'object' ? errors[index] : undefined; - return ( -
- {Item ? ( - handleChangeItem(key, value)} - error={error} - disabled={disabled} - readOnly={readOnly} - /> - ) : ( - renderItem( - item, - (value: T) => handleChangeItem(key, value), - error - ) - )} -
- {!readOnly && movable && ( - <> -
-
- ); - })} + ); + })} +
+ )} +
+ {!(isAddButtonHidden || readOnly) && ( + + )}
); diff --git a/app/react/docker/containers/CreateView/Gpu.tsx b/app/react/docker/containers/CreateView/Gpu.tsx index e26114097..fc72f5582 100644 --- a/app/react/docker/containers/CreateView/Gpu.tsx +++ b/app/react/docker/containers/CreateView/Gpu.tsx @@ -10,6 +10,7 @@ import { OptionProps } from 'react-select/dist/declarations/src/components/Optio import { Select } from '@@/form-components/ReactSelect'; import { Switch } from '@@/form-components/SwitchField/Switch'; import { Tooltip } from '@@/Tip/Tooltip'; +import { TextTip } from '@@/Tip/TextTip'; interface Values { enabled: boolean; @@ -35,6 +36,7 @@ export interface Props { gpus: GPU[]; usedGpus: string[]; usedAllGpus: boolean; + enableGpuManagement?: boolean; } const NvidiaCapabilitiesOptions = [ @@ -103,6 +105,7 @@ export function Gpu({ gpus = [], usedGpus = [], usedAllGpus, + enableGpuManagement, }: Props) { const options = useMemo(() => { const options = (gpus || []).map((gpu) => ({ @@ -181,30 +184,38 @@ export function Gpu({ return (
+ {!enableGpuManagement && ( + + GPU in the UI is not currently enabled for this environment. + + )}
Enable GPU
-
- - isMulti - closeMenuOnSelect - value={gpuValue} - isClearable={false} - backspaceRemovesValue={false} - isDisabled={!values.enabled} - onChange={onChangeSelectedGpus} - options={options} - components={{ MultiValueRemove }} - /> -
+ {enableGpuManagement && values.enabled && ( +
+ + isMulti + closeMenuOnSelect + value={gpuValue} + isClearable={false} + backspaceRemovesValue={false} + isDisabled={!values.enabled} + onChange={onChangeSelectedGpus} + options={options} + components={{ MultiValueRemove }} + /> +
+ )}
{values.enabled && ( diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx index a645e6216..d1a0ba854 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx @@ -15,7 +15,7 @@ import { gpus } from './gpus'; export function useColumns( isHostColumnVisible: boolean, - isGPUsColumnVisible: boolean + isGPUsColumnVisible?: boolean ) { return useMemo( () => diff --git a/app/react/docker/containers/utils.ts b/app/react/docker/containers/utils.ts index 54c785bb2..6bb0ee242 100644 --- a/app/react/docker/containers/utils.ts +++ b/app/react/docker/containers/utils.ts @@ -1,8 +1,9 @@ import _ from 'lodash'; -import { useInfo } from '@/docker/services/system.service'; -import { EnvironmentId } from '@/react/portainer/environments/types'; import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { useInfo } from '@/docker/services/system.service'; +import { useEnvironment } from '@/react/portainer/environments/queries'; import { DockerContainer, ContainerStatus } from './types'; import { DockerContainerResponse } from './types/response'; @@ -95,10 +96,13 @@ function createStatus(statusText = ''): ContainerStatus { } export function useShowGPUsColumn(environmentID: EnvironmentId) { - const envInfoQuery = useInfo( + const isDockerStandaloneQuery = useInfo( environmentID, - (info) => !!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable + (info) => !(!!info.Swarm?.NodeID && !!info.Swarm?.ControlAvailable) // is not a swarm environment, therefore docker standalone ); - - return envInfoQuery.data !== true && !envInfoQuery.isLoading; + const enableGPUManagementQuery = useEnvironment( + environmentID, + (env) => env?.EnableGPUManagement + ); + return isDockerStandaloneQuery.data && enableGPUManagementQuery.data; } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList.tsx b/app/react/docker/host/SetupView/GpusList.tsx similarity index 90% rename from app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList.tsx rename to app/react/docker/host/SetupView/GpusList.tsx index 738d9e3ca..1f3397cb7 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList.tsx +++ b/app/react/docker/host/SetupView/GpusList.tsx @@ -48,11 +48,12 @@ function Item({ item, onChange }: ItemProps) { export function GpusList({ value, onChange }: Props) { return ( - label="GPU" + label="GPUs" + tooltip="You may optionally set up the GPUs that will be selectable against containers, although 'All GPUs' will always be available." value={value} onChange={onChange} itemBuilder={() => ({ value: '', name: '' })} - addLabel="add" + addLabel="Add GPU" item={Item} /> ); diff --git a/app/react/portainer/environments/environment.service/create.ts b/app/react/portainer/environments/environment.service/create.ts index 78347d0ef..ba3243e1a 100644 --- a/app/react/portainer/environments/environment.service/create.ts +++ b/app/react/portainer/environments/environment.service/create.ts @@ -1,4 +1,3 @@ -import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { type EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; import { type TagId } from '@/portainer/tags/types'; @@ -18,7 +17,6 @@ interface CreateLocalDockerEnvironment { socketPath?: string; publicUrl?: string; meta?: EnvironmentMetadata; - gpus?: Gpu[]; } export async function createLocalDockerEnvironment({ @@ -26,7 +24,6 @@ export async function createLocalDockerEnvironment({ socketPath = '', publicUrl = '', meta = { tagIds: [] }, - gpus = [], }: CreateLocalDockerEnvironment) { const url = prefixPath(socketPath); @@ -37,7 +34,6 @@ export async function createLocalDockerEnvironment({ url, publicUrl, meta, - gpus, } ); @@ -113,7 +109,6 @@ export interface EnvironmentOptions { azure?: AzureSettings; tls?: TLSSettings; isEdgeDevice?: boolean; - gpus?: Gpu[]; pollFrequency?: number; edge?: EdgeSettings; tunnelServerAddr?: string; @@ -145,7 +140,6 @@ export interface CreateAgentEnvironmentValues { name: string; environmentUrl: string; meta: EnvironmentMetadata; - gpus: Gpu[]; } export function createAgentEnvironment({ @@ -173,7 +167,6 @@ interface CreateEdgeAgentEnvironment { tunnelServerAddr?: string; meta?: EnvironmentMetadata; pollFrequency: number; - gpus?: Gpu[]; isEdgeDevice?: boolean; edge: EdgeSettings; } @@ -182,7 +175,6 @@ export function createEdgeAgentEnvironment({ name, portainerUrl, meta = { tagIds: [] }, - gpus = [], isEdgeDevice, pollFrequency, edge, @@ -196,7 +188,6 @@ export function createEdgeAgentEnvironment({ skipVerify: true, skipClientVerify: true, }, - gpus, isEdgeDevice, pollFrequency, edge, @@ -226,7 +217,6 @@ async function createEnvironment( TagIds: arrayToJson(tagIds), CheckinInterval: options.pollFrequency, IsEdgeDevice: options.isEdgeDevice, - Gpus: arrayToJson(options.gpus), }; const { tls, azure } = options; diff --git a/app/react/portainer/environments/queries/useEnvironment.ts b/app/react/portainer/environments/queries/useEnvironment.ts index 63f98e031..0ce1563e3 100644 --- a/app/react/portainer/environments/queries/useEnvironment.ts +++ b/app/react/portainer/environments/queries/useEnvironment.ts @@ -1,11 +1,18 @@ import { useQuery } from 'react-query'; import { getEndpoint } from '@/react/portainer/environments/environment.service'; -import { EnvironmentId } from '@/react/portainer/environments/types'; +import { + Environment, + EnvironmentId, +} from '@/react/portainer/environments/types'; import { withError } from '@/react-tools/react-query'; -export function useEnvironment(id?: EnvironmentId) { +export function useEnvironment( + id?: EnvironmentId, + select?: (environment: Environment | null) => T +) { return useQuery(['environments', id], () => (id ? getEndpoint(id) : null), { + select, ...withError('Failed loading environment'), staleTime: 50, enabled: !!id, diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts index 418e338e0..639bad9f1 100644 --- a/app/react/portainer/environments/types.ts +++ b/app/react/portainer/environments/types.ts @@ -131,6 +131,7 @@ export type Environment = { TagIds: TagId[]; GroupId: EnvironmentGroupId; DeploymentOptions: DeploymentOptions | null; + EnableGPUManagement: boolean; EdgeID?: string; EdgeKey: string; EdgeCheckinInterval?: number; diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APIForm.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APIForm.tsx index c1c23ed3f..b2c95b0b1 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APIForm.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APIForm.tsx @@ -3,7 +3,6 @@ import { useReducer } from 'react'; import { Plug2 } from 'lucide-react'; import { useCreateRemoteEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation'; -import { Hardware } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/Hardware'; import { notifySuccess } from '@/portainer/services/notifications'; import { Environment, @@ -13,6 +12,7 @@ import { import { LoadingButton } from '@@/buttons/LoadingButton'; import { FormControl } from '@@/form-components/FormControl'; import { Input } from '@@/form-components/Input'; +import { InsightsBox } from '@@/InsightsBox'; import { NameField } from '../../shared/NameField'; import { MoreSettingsSection } from '../../shared/MoreSettingsSection'; @@ -23,9 +23,10 @@ import { TLSFieldset } from './TLSFieldset'; interface Props { onCreate(environment: Environment): void; + isDockerStandalone?: boolean; } -export function APIForm({ onCreate }: Props) { +export function APIForm({ onCreate, isDockerStandalone }: Props) { const [formKey, clearForm] = useReducer((state) => state + 1, 0); const initialValues: FormValues = { url: '', @@ -35,7 +36,6 @@ export function APIForm({ onCreate }: Props) { groupId: 1, tagIds: [], }, - gpus: [], }; const mutation = useCreateRemoteEnvironmentMutation( @@ -73,7 +73,33 @@ export function APIForm({ onCreate }: Props) { - + {isDockerStandalone && ( + +

+ From 2.18 on, the set-up of available GPUs for a Docker + Standalone environment has been shifted from Add + environment and Environment details to Host -> Setup, + so as to align with other settings. +

+

+ A toggle has been introduced for enabling/disabling + management of GPU settings in the Portainer UI - to + alleviate the performance impact of showing those + settings. +

+

+ The UI has been updated to clarify that GPU settings + support is only for Docker Standalone (and not Docker + Swarm, which was never supported in the UI). +

+ + } + header="GPU settings update" + insightCloseId="gpu-settings-update-closed" + /> + )}
@@ -104,7 +130,6 @@ export function APIForm({ onCreate }: Props) { options: { tls, meta: values.meta, - gpus: values.gpus, }, }, { diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APIForm.validation.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APIForm.validation.tsx index d34f49a60..89a935735 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APIForm.validation.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APIForm.validation.tsx @@ -1,7 +1,5 @@ import { boolean, object, SchemaOf, string } from 'yup'; -import { gpusListValidation } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; - import { metadataValidation } from '../../shared/MetadataFieldset/validation'; import { useNameValidation } from '../../shared/NameField'; @@ -16,6 +14,5 @@ export function useValidation(): SchemaOf { skipVerify: boolean(), meta: metadataValidation(), ...certsValidation(), - gpus: gpusListValidation(), }); } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APITab.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APITab.tsx index 3b39cd44d..21cd917fa 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APITab.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/APITab.tsx @@ -4,12 +4,13 @@ import { APIForm } from './APIForm'; interface Props { onCreate(environment: Environment): void; + isDockerStandalone?: boolean; } -export function APITab({ onCreate }: Props) { +export function APITab({ onCreate, isDockerStandalone }: Props) { return (
- +
); } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/types.ts b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/types.ts index 4112267d5..2d2143ccb 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/types.ts +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/APITab/types.ts @@ -1,4 +1,3 @@ -import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create'; export interface FormValues { @@ -10,5 +9,4 @@ export interface FormValues { certFile?: File; keyFile?: File; meta: EnvironmentMetadata; - gpus?: Gpu[]; } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.tsx index d45dea166..b6ed8c920 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.tsx @@ -3,7 +3,6 @@ import { useReducer } from 'react'; import { Plug2 } from 'lucide-react'; import { useCreateLocalDockerEnvironmentMutation } from '@/react/portainer/environments/queries/useCreateEnvironmentMutation'; -import { Hardware } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/Hardware'; import { notifySuccess } from '@/portainer/services/notifications'; import { Environment } from '@/react/portainer/environments/types'; @@ -11,6 +10,7 @@ import { LoadingButton } from '@@/buttons/LoadingButton'; import { FormControl } from '@@/form-components/FormControl'; import { Input } from '@@/form-components/Input'; import { SwitchField } from '@@/form-components/SwitchField'; +import { InsightsBox } from '@@/InsightsBox'; import { NameField } from '../../shared/NameField'; import { MoreSettingsSection } from '../../shared/MoreSettingsSection'; @@ -20,16 +20,16 @@ import { FormValues } from './types'; interface Props { onCreate(environment: Environment): void; + isDockerStandalone?: boolean; } -export function SocketForm({ onCreate }: Props) { +export function SocketForm({ onCreate, isDockerStandalone }: Props) { const [formKey, clearForm] = useReducer((state) => state + 1, 0); const initialValues: FormValues = { name: '', socketPath: '', overridePath: false, meta: { groupId: 1, tagIds: [] }, - gpus: [], }; const mutation = useCreateLocalDockerEnvironmentMutation(); @@ -50,7 +50,33 @@ export function SocketForm({ onCreate }: Props) { - + {isDockerStandalone && ( + +

+ From 2.18 on, the set-up of available GPUs for a Docker + Standalone environment has been shifted from Add + environment and Environment details to Host -> Setup, + so as to align with other settings. +

+

+ A toggle has been introduced for enabling/disabling + management of GPU settings in the Portainer UI - to + alleviate the performance impact of showing those + settings. +

+

+ The UI has been updated to clarify that GPU settings + support is only for Docker Standalone (and not Docker + Swarm, which was never supported in the UI). +

+ + } + header="GPU settings update" + insightCloseId="gpu-settings-update-closed" + /> + )}
@@ -76,7 +102,6 @@ export function SocketForm({ onCreate }: Props) { { name: values.name, socketPath: values.overridePath ? values.socketPath : '', - gpus: values.gpus, meta: values.meta, }, { diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.validation.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.validation.tsx index 6f336e820..f8b48cf0d 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.validation.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketForm.validation.tsx @@ -1,7 +1,5 @@ import { boolean, object, SchemaOf, string } from 'yup'; -import { gpusListValidation } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; - import { metadataValidation } from '../../shared/MetadataFieldset/validation'; import { useNameValidation } from '../../shared/NameField'; @@ -21,6 +19,5 @@ export function useValidation(): SchemaOf { ) : schema ), - gpus: gpusListValidation(), }); } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketTab.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketTab.tsx index a93b0e253..3fbbf6281 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketTab.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/SocketTab.tsx @@ -6,15 +6,19 @@ import { SocketForm } from './SocketForm'; interface Props { onCreate(environment: Environment): void; + isDockerStandalone?: boolean; } -export function SocketTab({ onCreate }: Props) { +export function SocketTab({ onCreate, isDockerStandalone }: Props) { return ( <>
- +
); diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/types.ts b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/types.ts index 84622bd39..9bc7fdad1 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/types.ts +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/SocketTab/types.ts @@ -1,4 +1,3 @@ -import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create'; export interface FormValues { @@ -6,5 +5,4 @@ export interface FormValues { socketPath: string; overridePath: boolean; meta: EnvironmentMetadata; - gpus: Gpu[]; } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx index 4e8fb2746..181dc2885 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/WizardDocker/WizardDocker.tsx @@ -109,12 +109,14 @@ export function WizardDocker({ onCreate, isDockerStandalone }: Props) { return ( onCreate(environment, 'dockerApi')} + isDockerStandalone={isDockerStandalone} /> ); case 'socket': return ( onCreate(environment, 'localEndpoint')} + isDockerStandalone={isDockerStandalone} /> ); case 'edgeAgentStandard': diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.tsx index dc0eeacde..dcedcc512 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.tsx @@ -11,14 +11,12 @@ import { LoadingButton } from '@@/buttons/LoadingButton'; import { NameField } from '../NameField'; import { MoreSettingsSection } from '../MoreSettingsSection'; -import { Hardware } from '../Hardware/Hardware'; import { EnvironmentUrlField } from './EnvironmentUrlField'; import { useValidation } from './AgentForm.validation'; interface Props { onCreate(environment: Environment): void; - showGpus?: boolean; } const initialValues: CreateAgentEnvironmentValues = { @@ -28,10 +26,9 @@ const initialValues: CreateAgentEnvironmentValues = { groupId: 1, tagIds: [], }, - gpus: [], }; -export function AgentForm({ onCreate, showGpus = false }: Props) { +export function AgentForm({ onCreate }: Props) { const [formKey, clearForm] = useReducer((state) => state + 1, 0); const mutation = useCreateAgentEnvironmentMutation(); @@ -50,7 +47,7 @@ export function AgentForm({ onCreate, showGpus = false }: Props) { - {showGpus && } +
diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.validation.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.validation.tsx index 003c6b253..4829d9bbf 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.validation.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/AgentForm/AgentForm.validation.tsx @@ -1,6 +1,5 @@ import { object, SchemaOf, string } from 'yup'; -import { gpusListValidation } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; import { CreateAgentEnvironmentValues } from '@/react/portainer/environments/environment.service/create'; import { metadataValidation } from '../MetadataFieldset/validation'; @@ -11,7 +10,6 @@ export function useValidation(): SchemaOf { name: useNameValidation(), environmentUrl: environmentValidation(), meta: metadataValidation(), - gpus: gpusListValidation(), }); } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx index 8d260520c..fe0d5e0fe 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx @@ -17,7 +17,6 @@ import { FormSection } from '@@/form-components/FormSection'; import { LoadingButton } from '@@/buttons/LoadingButton'; import { MoreSettingsSection } from '../../MoreSettingsSection'; -import { Hardware } from '../../Hardware/Hardware'; import { EdgeAgentFieldset } from './EdgeAgentFieldset'; import { useValidationSchema } from './EdgeAgentForm.validation'; @@ -26,16 +25,10 @@ import { FormValues } from './types'; interface Props { onCreate(environment: Environment): void; readonly: boolean; - showGpus?: boolean; asyncMode: boolean; } -export function EdgeAgentForm({ - onCreate, - readonly, - asyncMode, - showGpus = false, -}: Props) { +export function EdgeAgentForm({ onCreate, readonly, asyncMode }: Props) { const settingsQuery = useSettings(); const createMutation = useCreateEdgeAgentEnvironmentMutation(); @@ -76,7 +69,6 @@ export function EdgeAgentForm({ /> )} - {showGpus && } {!readonly && ( @@ -133,6 +125,5 @@ export function buildInitialValues(settings: Settings): FormValues { PingInterval: EDGE_ASYNC_INTERVAL_USE_DEFAULT, SnapshotInterval: EDGE_ASYNC_INTERVAL_USE_DEFAULT, }, - gpus: [], }; } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.validation.ts b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.validation.ts index 5a2677777..b34c3fe2f 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.validation.ts +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.validation.ts @@ -4,7 +4,6 @@ import { edgeAsyncIntervalsValidation, EdgeAsyncIntervalsValues, } from '@/react/edge/components/EdgeAsyncIntervalsForm'; -import { gpusListValidation } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; import { validation as urlValidation } from '@/react/portainer/common/PortainerTunnelAddrField'; import { validation as addressValidation } from '@/react/portainer/common/PortainerUrlField'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; @@ -23,7 +22,6 @@ export function useValidationSchema(asyncMode: boolean): SchemaOf { tunnelServerAddr: asyncMode ? string() : addressValidation(), pollFrequency: number().required(), meta: metadataValidation(), - gpus: gpusListValidation(), edge: isBE ? edgeAsyncIntervalsValidation() : (null as unknown as SchemaOf), diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/types.ts b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/types.ts index 0ad061cf9..ddbdbb772 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/types.ts +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/types.ts @@ -1,5 +1,4 @@ import { EdgeAsyncIntervalsValues } from '@/react/edge/components/EdgeAsyncIntervalsForm'; -import { Gpu } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; import { EnvironmentMetadata } from '@/react/portainer/environments/environment.service/create'; export interface FormValues { @@ -9,7 +8,6 @@ export interface FormValues { tunnelServerAddr?: string; pollFrequency: number; meta: EnvironmentMetadata; - gpus: Gpu[]; edge: EdgeAsyncIntervalsValues; } diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentTab.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentTab.tsx index 6739c2f26..ca9d0774e 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentTab.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentTab.tsx @@ -15,7 +15,6 @@ interface Props { onCreate: (environment: Environment) => void; commands: CommandTab[] | Partial>; isNomadTokenVisible?: boolean; - showGpus?: boolean; asyncMode?: boolean; } @@ -23,7 +22,6 @@ export function EdgeAgentTab({ onCreate, commands, isNomadTokenVisible, - showGpus = false, asyncMode = false, }: Props) { const [edgeInfo, setEdgeInfo] = useState(); @@ -35,7 +33,6 @@ export function EdgeAgentTab({ onCreate={handleCreate} readonly={!!edgeInfo} key={formKey} - showGpus={showGpus} asyncMode={asyncMode} /> diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/Hardware.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/Hardware.tsx deleted file mode 100644 index 7e403113b..000000000 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/Hardware.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { useField } from 'formik'; - -import { - Gpu, - GpusList, -} from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/Hardware/GpusList'; - -import { FormSection } from '@@/form-components/FormSection'; - -export function Hardware() { - const [field, , helpers] = useField('gpus'); - - function onChange(value: Gpu[]) { - helpers.setValue(value); - } - - return ( - - - - ); -} From 07df4b15912aab10ed911a68d8869985fea580e2 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Fri, 3 Mar 2023 15:08:15 +1300 Subject: [PATCH 04/55] fix(notifications): limit only in header [EE-4815] (#8579) Co-authored-by: testa113 --- app/react/components/PageHeader/NotificationsMenu.tsx | 9 +++++---- .../portainer/notifications/notifications-store.ts | 11 ++--------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/app/react/components/PageHeader/NotificationsMenu.tsx b/app/react/components/PageHeader/NotificationsMenu.tsx index 69b0f35e7..e9639ddfe 100644 --- a/app/react/components/PageHeader/NotificationsMenu.tsx +++ b/app/react/components/PageHeader/NotificationsMenu.tsx @@ -33,6 +33,7 @@ export function NotificationsMenu() { notificationsStore, (state) => state.userNotifications[user.Id] ); + const reducedNotifications = userNotifications?.slice(0, 50); return ( @@ -55,7 +56,7 @@ export function NotificationsMenu() { 0 ? notificationStyles.badge : '' + reducedNotifications?.length > 0 ? notificationStyles.badge : '' } />
@@ -77,7 +78,7 @@ export function NotificationsMenu() {

Notifications

- {userNotifications?.length > 0 && ( + {reducedNotifications?.length > 0 && (
- {userNotifications?.length > 0 ? ( + {reducedNotifications?.length > 0 ? ( <>
- {userNotifications.map((notification) => ( + {reducedNotifications.map((notification) => ( ()( userNotifications: {}, addNotification: (userId: number, notification: ToastNotification) => { set((state) => { - const currentUserNotifications = - state.userNotifications[userId] || []; // keep the new notification at the start of the list, so sorting by newest time isn't required const newUserNotifications = [ notification, - ...currentUserNotifications, + ...(state.userNotifications[userId] || []), ]; - const maxNotifications = 50; - const reducedNotifications = newUserNotifications.slice( - 0, - maxNotifications - ); return { userNotifications: { ...state.userNotifications, - [userId]: reducedNotifications, + [userId]: newUserNotifications, }, }; }); From 60275dd31c639d86e4347fa9bfd66089ead229af Mon Sep 17 00:00:00 2001 From: cmeng Date: Sat, 4 Mar 2023 09:13:37 +1300 Subject: [PATCH 05/55] feat(edge): EE-4621 support high latency for tunnel (#8302) --- app/docker/__module.js | 23 ++++++++---- app/kubernetes/__module.js | 30 ++++++++++++---- app/portainer/__module.js | 2 +- app/react/components/modals/Dialog.tsx | 35 ++++++++++++++++-- app/react/components/modals/types.ts | 1 + app/react/components/modals/utils.ts | 5 +-- .../HomeView/EdgeLoadingSpinner.module.css | 2 +- app/react/portainer/HomeView/HomeView.tsx | 36 ++++++++++++++++--- 8 files changed, 111 insertions(+), 23 deletions(-) diff --git a/app/docker/__module.js b/app/docker/__module.js index 74e9a80dd..de09c8719 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -1,5 +1,7 @@ import angular from 'angular'; +import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; + import { EnvironmentStatus } from '@/react/portainer/environments/types'; import { reactModule } from './react'; @@ -16,14 +18,17 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([ abstract: true, onEnter: /* @ngInject */ function onEnter(endpoint, $async, $state, EndpointService, Notifications, StateManager, SystemService) { return $async(async () => { - if (![1, 2, 4].includes(endpoint.Type)) { + const dockerTypes = [PortainerEndpointTypes.DockerEnvironment, PortainerEndpointTypes.AgentOnDockerEnvironment, PortainerEndpointTypes.EdgeAgentOnDockerEnvironment]; + + if (!dockerTypes.includes(endpoint.Type)) { $state.go('portainer.home'); return; } + try { const status = await checkEndpointStatus(endpoint); - if (endpoint.Type !== 4) { + if (endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) { await updateEndpointStatus(endpoint, status); } endpoint.Status = status; @@ -34,16 +39,22 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([ await StateManager.updateEndpointState(endpoint); } catch (e) { - Notifications.error('Failed loading environment', e); - $state.go('portainer.home', {}, { reload: true }); + let params = {}; + + if (endpoint.Type == PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) { + params = { redirect: true, environmentId: endpoint.Id, environmentName: endpoint.Name, route: 'docker.dashboard' }; + } else { + Notifications.error('Failed loading environment', e); + } + $state.go('portainer.home', params, { reload: true, inherit: false }); } async function checkEndpointStatus(endpoint) { try { await SystemService.ping(endpoint.Id); - return 1; + return EnvironmentStatus.Up; } catch (e) { - return 2; + return EnvironmentStatus.Down; } } diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index f3edc768d..5ebe986fb 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -1,3 +1,7 @@ +import { EnvironmentStatus } from '@/react/portainer/environments/types'; + +import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; + import registriesModule from './registries'; import customTemplateModule from './custom-templates'; import { reactModule } from './react'; @@ -16,31 +20,43 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo onEnter: /* @ngInject */ function onEnter($async, $state, endpoint, KubernetesHealthService, KubernetesNamespaceService, Notifications, StateManager) { return $async(async () => { - if (![5, 6, 7].includes(endpoint.Type)) { + const kubeTypes = [ + PortainerEndpointTypes.KubernetesLocalEnvironment, + PortainerEndpointTypes.AgentOnKubernetesEnvironment, + PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment, + ]; + + if (!kubeTypes.includes(endpoint.Type)) { $state.go('portainer.home'); return; } try { - if (endpoint.Type === 7) { + if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) { //edge try { await KubernetesHealthService.ping(endpoint.Id); - endpoint.Status = 1; + endpoint.Status = EnvironmentStatus.Up; } catch (e) { - endpoint.Status = 2; + endpoint.Status = EnvironmentStatus.Down; } } await StateManager.updateEndpointState(endpoint); - if (endpoint.Type === 7 && endpoint.Status === 2) { + if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment && endpoint.Status === EnvironmentStatus.Down) { throw new Error('Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.'); } await KubernetesNamespaceService.get(); } catch (e) { - Notifications.error('Failed loading environment', e); - $state.go('portainer.home', {}, { reload: true }); + let params = {}; + + if (endpoint.Type == PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) { + params = { redirect: true, environmentId: endpoint.Id, environmentName: endpoint.Name, route: 'kubernetes.dashboard' }; + } else { + Notifications.error('Failed loading environment', e); + } + $state.go('portainer.home', params, { reload: true, inherit: false }); } }); }, diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 98d6f21af..b43401dd5 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -300,7 +300,7 @@ angular var home = { name: 'portainer.home', - url: '/home', + url: '/home?redirect&environmentId&environmentName&route', views: { 'content@': { component: 'homeView', diff --git a/app/react/components/modals/Dialog.tsx b/app/react/components/modals/Dialog.tsx index c46cdc7c1..39c5e033e 100644 --- a/app/react/components/modals/Dialog.tsx +++ b/app/react/components/modals/Dialog.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ReactNode, useEffect, useState, useRef } from 'react'; import { Button } from '@@/buttons'; @@ -26,6 +26,37 @@ export function Dialog({ }: Props) { const ariaLabel = requireString(title) || requireString(message) || 'Dialog'; + const [count, setCount] = useState(0); + const countRef = useRef(count); + countRef.current = count; + + useEffect(() => { + let retFn; + + // only countdown the first button with non-zero timeout + for (let i = 0; i < buttons.length; i++) { + const button = buttons[i]; + if (button.timeout) { + setCount(button.timeout as number); + + const intervalID = setInterval(() => { + const count = countRef.current; + + setCount(count - 1); + if (count === 1) { + onSubmit(button.value); + } + }, 1000); + + retFn = () => clearInterval(intervalID); + + break; + } + } + + return retFn; + }, [buttons, onSubmit]); + return ( onSubmit()} aria-label={ariaLabel}> {title && } @@ -39,7 +70,7 @@ export function Dialog({ key={index} size="medium" > - {button.label} + {button.label} {button.timeout && count ? `(${count})` : null} ))} diff --git a/app/react/components/modals/types.ts b/app/react/components/modals/types.ts index 6720d8622..b56a3a352 100644 --- a/app/react/components/modals/types.ts +++ b/app/react/components/modals/types.ts @@ -7,6 +7,7 @@ export interface ButtonOptions { className?: string; color?: ComponentProps['color']; value?: TValue; + timeout?: number; } export interface ButtonsOptions { diff --git a/app/react/components/modals/utils.ts b/app/react/components/modals/utils.ts index 62de850e0..ec0f7d5c7 100644 --- a/app/react/components/modals/utils.ts +++ b/app/react/components/modals/utils.ts @@ -6,9 +6,10 @@ import { ButtonOptions } from './types'; export function buildConfirmButton( label = 'Confirm', - color: ComponentProps['color'] = 'primary' + color: ComponentProps['color'] = 'primary', + timeout = 0 ): ButtonOptions { - return { label, color, value: true }; + return { label, color, value: true, timeout }; } export function buildCancelButton(label = 'Cancel'): ButtonOptions { diff --git a/app/react/portainer/HomeView/EdgeLoadingSpinner.module.css b/app/react/portainer/HomeView/EdgeLoadingSpinner.module.css index 82dc7f30c..d6ec1181c 100644 --- a/app/react/portainer/HomeView/EdgeLoadingSpinner.module.css +++ b/app/react/portainer/HomeView/EdgeLoadingSpinner.module.css @@ -3,7 +3,7 @@ height: 100%; text-align: center; display: flex; - flex-direction: column; + flex-direction: row; align-items: center; justify-content: center; } diff --git a/app/react/portainer/HomeView/HomeView.tsx b/app/react/portainer/HomeView/HomeView.tsx index 8961ecbf3..6e5e83ad6 100644 --- a/app/react/portainer/HomeView/HomeView.tsx +++ b/app/react/portainer/HomeView/HomeView.tsx @@ -1,5 +1,5 @@ -import { useRouter } from '@uirouter/react'; -import { useState } from 'react'; +import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; +import { useEffect, useState } from 'react'; import { Environment } from '@/react/portainer/environments/types'; import { snapshotEndpoints } from '@/react/portainer/environments/environment.service'; @@ -9,6 +9,7 @@ import * as notifications from '@/portainer/services/notifications'; import { confirm } from '@@/modals/confirm'; import { PageHeader } from '@@/PageHeader'; import { ModalType } from '@@/modals'; +import { buildConfirmButton } from '@@/modals/utils'; import { EnvironmentList } from './EnvironmentList'; import { EdgeLoadingSpinner } from './EdgeLoadingSpinner'; @@ -17,10 +18,37 @@ import { LicenseNodePanel } from './LicenseNodePanel'; import { BackupFailedPanel } from './BackupFailedPanel'; export function HomeView() { - const [connectingToEdgeEndpoint, setConnectingToEdgeEndpoint] = - useState(false); + const { params } = useCurrentStateAndParams(); + const [connectingToEdgeEndpoint, setConnectingToEdgeEndpoint] = useState( + !!params.redirect + ); const router = useRouter(); + + useEffect(() => { + async function redirect() { + const options = { + title: `Failed connecting to ${params.environmentName}`, + message: `There was an issue connecting to edge agent via tunnel. Click 'Retry' below to retry now, or wait 10 seconds to automatically retry.`, + confirmButton: buildConfirmButton('Retry', 'primary', 10), + modalType: ModalType.Destructive, + }; + + if (await confirm(options)) { + setConnectingToEdgeEndpoint(true); + router.stateService.go(params.route, { + endpointId: params.environmentId, + }); + } else { + router.stateService.go('portainer.home', {}, { inherit: false }); + } + } + + if (params.redirect) { + redirect(); + } + }, [params, setConnectingToEdgeEndpoint, router]); + return ( <> Date: Sun, 5 Mar 2023 12:14:08 +0200 Subject: [PATCH 06/55] fix(ui/input-group): remove z-index [EE-5121] (#8582) --- app/assets/css/vendor-override.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css index 6135d55d9..5f83a6583 100644 --- a/app/assets/css/vendor-override.css +++ b/app/assets/css/vendor-override.css @@ -36,6 +36,10 @@ border: 1px solid var(--border-input-group-addon-color); } +.input-group .form-control { + z-index: unset; +} + .text-danger { color: var(--ui-error-9); } From 3baab6d69515d3c102c6a426c9de8a39c9ad0013 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 5 Mar 2023 15:22:00 +0200 Subject: [PATCH 07/55] fix(ui): import components for envs and registries [EE-5120] (#8583) --- ...nts.ts => environments-list-view-components.ts} | 0 .../components/{envronments.ts => environments.ts} | 0 app/portainer/react/components/index.ts | 14 ++++++++------ 3 files changed, 8 insertions(+), 6 deletions(-) rename app/portainer/react/components/{enviroments-list-view-components.ts => environments-list-view-components.ts} (100%) rename app/portainer/react/components/{envronments.ts => environments.ts} (100%) diff --git a/app/portainer/react/components/enviroments-list-view-components.ts b/app/portainer/react/components/environments-list-view-components.ts similarity index 100% rename from app/portainer/react/components/enviroments-list-view-components.ts rename to app/portainer/react/components/environments-list-view-components.ts diff --git a/app/portainer/react/components/envronments.ts b/app/portainer/react/components/environments.ts similarity index 100% rename from app/portainer/react/components/envronments.ts rename to app/portainer/react/components/environments.ts diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index e949a5013..fa2f87738 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -33,17 +33,19 @@ import { customTemplatesModule } from './custom-templates'; import { gitFormModule } from './git-form'; import { settingsModule } from './settings'; import { accessControlModule } from './access-control'; -import { environmentsModule } from './envronments'; -import { envListModule } from './enviroments-list-view-components'; +import { environmentsModule } from './environments'; +import { envListModule } from './environments-list-view-components'; +import { registriesModule } from './registries'; export const componentsModule = angular .module('portainer.app.react.components', [ - customTemplatesModule, - gitFormModule, - settingsModule, accessControlModule, - environmentsModule, + customTemplatesModule, envListModule, + environmentsModule, + gitFormModule, + registriesModule, + settingsModule, ]) .component( 'tagSelector', From 77e1f5aa34f9058403f866e4638672c135cf28ab Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 5 Mar 2023 15:22:49 +0200 Subject: [PATCH 08/55] fix(oauth): danger confirm for hiding prompt [EE-4576] (#8574) --- .../components/oauth-settings/oauth-settings.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js b/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js index 52d2e4867..dd2fbde56 100644 --- a/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js +++ b/app/portainer/oauth/components/oauth-settings/oauth-settings.controller.js @@ -87,7 +87,7 @@ export default class OAuthSettingsController { const confirmed = await confirm({ title: 'Hide internal authentication prompt', message: 'By hiding internal authentication prompt, you will only be able to login via SSO. Are you sure?', - confirmButton: buildConfirmButton('Confirm', 'btn-warning'), + confirmButton: buildConfirmButton('Confirm', 'danger'), modalType: ModalType.Warn, }); From 4c6f5f961e59536e41772a6b6103281469d637d6 Mon Sep 17 00:00:00 2001 From: cmeng Date: Mon, 6 Mar 2023 10:43:08 +1300 Subject: [PATCH 09/55] fix(wizard): fix tunnel add validation [EE-5124] (#8595) --- .../shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx | 2 +- .../EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.validation.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx index fe0d5e0fe..58432adbe 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.tsx @@ -32,7 +32,7 @@ export function EdgeAgentForm({ onCreate, readonly, asyncMode }: Props) { const settingsQuery = useSettings(); const createMutation = useCreateEdgeAgentEnvironmentMutation(); - const validation = useValidationSchema(asyncMode); + const validation = useValidationSchema(); if (!settingsQuery.data) { return null; diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.validation.ts b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.validation.ts index b34c3fe2f..09fb72517 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.validation.ts +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/EdgeAgentTab/EdgeAgentForm/EdgeAgentForm.validation.ts @@ -5,7 +5,6 @@ import { EdgeAsyncIntervalsValues, } from '@/react/edge/components/EdgeAsyncIntervalsForm'; import { validation as urlValidation } from '@/react/portainer/common/PortainerTunnelAddrField'; -import { validation as addressValidation } from '@/react/portainer/common/PortainerUrlField'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { metadataValidation } from '../../MetadataFieldset/validation'; @@ -13,13 +12,13 @@ import { useNameValidation } from '../../NameField'; import { FormValues } from './types'; -export function useValidationSchema(asyncMode: boolean): SchemaOf { +export function useValidationSchema(): SchemaOf { const nameValidation = useNameValidation(); return object().shape({ name: nameValidation, portainerUrl: urlValidation(), - tunnelServerAddr: asyncMode ? string() : addressValidation(), + tunnelServerAddr: string(), pollFrequency: number().required(), meta: metadataValidation(), edge: isBE From 07100258cdaa009512ef0f3516ef69fd7d0f84db Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 6 Mar 2023 09:13:22 +0200 Subject: [PATCH 10/55] fix(git): file path background [EE-5114] (#8573) * fix(git): file path background [EE-5114] also disabled url check on CE because the http handler wasn't available and raised errors * fix(git): highlight hovered path option * feat(git): hide path options when choosing --- .../gitops/ComposePathField/PathSelector.tsx | 13 +++++++++---- app/react/portainer/gitops/GitFormUrlField.tsx | 4 ++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/react/portainer/gitops/ComposePathField/PathSelector.tsx b/app/react/portainer/gitops/ComposePathField/PathSelector.tsx index e3ee21978..935edcd87 100644 --- a/app/react/portainer/gitops/ComposePathField/PathSelector.tsx +++ b/app/react/portainer/gitops/ComposePathField/PathSelector.tsx @@ -7,6 +7,7 @@ import { } from '@reach/combobox'; import '@reach/combobox/styles.css'; import { ChangeEvent } from 'react'; +import clsx from 'clsx'; import { useSearch } from '@/react/portainer/gitops/queries/useSearch'; import { useDebounce } from '@/react/hooks/useDebounce'; @@ -41,7 +42,7 @@ export function PathSelector({ const enabled = Boolean( model.RepositoryURL && model.RepositoryURLValid && searchTerm ); - const { data: searchResult } = useSearch(payload, enabled); + const { data: searchResults } = useSearch(payload, enabled); const { ref, updateCaret } = useCaretPosition(); return ( @@ -58,14 +59,18 @@ export function PathSelector({ placeholder={placeholder} value={value} /> - {searchResult && searchResult.length > 0 && searchTerm !== '' && ( + {searchResults && searchResults.length > 0 && ( - {searchResult.map((result: string, index: number) => ( + {searchResults.map((result: string, index: number) => ( ))} diff --git a/app/react/portainer/gitops/GitFormUrlField.tsx b/app/react/portainer/gitops/GitFormUrlField.tsx index 100b22de4..9d322789e 100644 --- a/app/react/portainer/gitops/GitFormUrlField.tsx +++ b/app/react/portainer/gitops/GitFormUrlField.tsx @@ -16,6 +16,8 @@ import { TextTip } from '@@/Tip/TextTip'; import { Button } from '@@/buttons'; import { useCachedValidation } from '@@/form-components/useCachedTest'; +import { isBE } from '../feature-flags/feature-flags.service'; + import { GitFormModel } from './types'; import { getAuthentication } from './utils'; @@ -43,6 +45,8 @@ export function GitFormUrlField({ onChangeRepositoryValid(!!isValid); setForce(false); }, + // disabled check on CE since it's not supported + enabled: isBE, }); const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange); From 03712966e4c24339cc354b7e4563cf4f205fea2d Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 6 Mar 2023 09:13:42 +0200 Subject: [PATCH 11/55] fix(ui/code-editor): apply theme colors [EE-5104] (#8558) * fix(ui/code-editor): apply theme colors [EE-5104] fix [EE-5104] * fix(kube/yaml): expand yaml inspector * fix(ui/code-editor): have default height * fix(ui/code-editor): boolean color * fix(ui/code-editor): style search bar --- app/assets/css/theme.css | 31 ++------ app/assets/css/vendor-override.css | 44 ------------ .../yaml-inspector/yamlInspector.html | 1 + .../yaml-inspector/yamlInspectorController.js | 3 - .../components/code-editor/code-editor.html | 1 + .../components/code-editor/code-editor.js | 1 + .../form-components/web-editor-form/index.js | 1 + .../web-editor-form/web-editor-form.html | 1 + app/react/components/CodeEditor.module.css | 72 +++++++++++++++++++ app/react/components/CodeEditor.tsx | 37 ++++++++-- package.json | 5 +- yarn.lock | 13 +++- 12 files changed, 128 insertions(+), 82 deletions(-) create mode 100644 app/react/components/CodeEditor.module.css diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index a052f6b29..d4a121a7b 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -120,8 +120,6 @@ --bg-navtabs-hover-color: var(--grey-16); --bg-nav-tab-active-color: var(--ui-gray-4); --bg-table-selected-color: var(--grey-14); - --bg-codemirror-color: var(--white-color); - --bg-codemirror-gutters-color: var(--grey-17); --bg-dropdown-menu-color: var(--white-color); --bg-log-viewer-color: var(--white-color); --bg-log-line-selected-color: var(--grey-18); @@ -136,7 +134,6 @@ --bg-item-highlighted-color: var(--grey-21); --bg-item-highlighted-null-color: var(--grey-14); --bg-panel-body-color: var(--white-color); - --bg-codemirror-selected-color: var(--grey-22); --bg-tooltip-color: var(--ui-gray-11); --bg-input-sm-color: var(--white-color); --bg-app-datatable-thead: var(--grey-23); @@ -182,11 +179,7 @@ --text-navtabs-color: var(--grey-7); --text-navtabs-hover-color: var(--grey-6); --text-nav-tab-active-color: var(--grey-25); - --text-cm-default-color: var(--blue-1); - --text-cm-meta-color: var(--black-color); - --text-cm-string-color: var(--red-3); - --text-cm-number-color: var(--green-1); - --text-codemirror-color: var(--black-color); + --text-dropdown-menu-color: var(--grey-6); --text-log-viewer-color: var(--black-color); --text-json-tree-color: var(--blue-3); @@ -224,7 +217,6 @@ --border-md-checkbox-color: var(--grey-19); --border-modal-header-color: var(--grey-45); --border-navtabs-color: var(--ui-white); - --border-codemirror-cursor-color: var(--black-color); --border-pre-color: var(--grey-43); --border-pagination-span-color: var(--ui-white); --border-pagination-hover-color: var(--ui-white); @@ -281,9 +273,6 @@ --bg-card-color: var(--grey-1); --bg-checkbox-border-color: var(--grey-8); --bg-code-color: var(--grey-2); - --bg-codemirror-color: var(--grey-2); - --bg-codemirror-gutters-color: var(--grey-3); - --bg-codemirror-selected-color: var(--grey-3); --bg-dropdown-menu-color: var(--ui-gray-warm-8); --bg-main-color: var(--grey-2); --bg-sidebar-color: var(--grey-1); @@ -361,11 +350,7 @@ --text-navtabs-color: var(--grey-8); --text-navtabs-hover-color: var(--grey-9); --text-nav-tab-active-color: var(--white-color); - --text-cm-default-color: var(--blue-10); - --text-cm-meta-color: var(--white-color); - --text-cm-string-color: var(--red-5); - --text-cm-number-color: var(--green-2); - --text-codemirror-color: var(--white-color); + --text-dropdown-menu-color: var(--white-color); --text-log-viewer-color: var(--white-color); --text-json-tree-color: var(--grey-40); @@ -403,7 +388,6 @@ --border-md-checkbox-color: var(--grey-41); --border-modal-header-color: var(--grey-1); --border-navtabs-color: var(--grey-38); - --border-codemirror-cursor-color: var(--white-color); --border-pre-color: var(--grey-3); --border-blocklist: var(--ui-gray-9); --border-blocklist-item-selected-color: var(--grey-38); @@ -468,15 +452,12 @@ --bg-switch-box-color: var(--grey-53); --bg-panel-body-color: var(--black-color); --bg-dropdown-menu-color: var(--ui-gray-warm-8); - --bg-codemirror-selected-color: var(--grey-3); --bg-motd-body-color: var(--black-color); --bg-blocklist-hover-color: var(--black-color); --bg-blocklist-item-selected-color: var(--black-color); --bg-input-group-addon-color: var(--grey-3); --bg-table-color: var(--black-color); - --bg-codemirror-gutters-color: var(--ui-gray-warm-11); - --bg-codemirror-color: var(--black-color); - --bg-codemirror-selected-color: var(--grey-3); + --bg-log-viewer-color: var(--black-color); --bg-log-line-selected-color: var(--grey-3); --bg-modal-content-color: var(--black-color); @@ -536,7 +517,6 @@ --text-tooltip-color: var(--white-color); --text-blocklist-item-selected-color: var(--blue-9); --text-input-group-addon-color: var(--white-color); - --text-codemirror-color: var(--white-color); --text-dropdown-menu-color: var(--white-color); --text-log-viewer-color: var(--white-color); --text-summary-color: var(--white-color); @@ -582,7 +562,6 @@ --border-pre-next-month: var(--white-color); --border-daterangepicker-after: var(--black-color); --border-pre-color: var(--grey-3); - --border-codemirror-cursor-color: var(--white-color); --border-modal: 1px solid var(--white-color); --border-sortbutton: var(--black-color); --border-bootbox: var(--black-color); @@ -596,9 +575,7 @@ --text-input-textarea: var(--black-color); --bg-item-highlighted-null-color: var(--grey-2); - --text-cm-default-color: var(--blue-9); - --text-cm-meta-color: var(--white-color); - --text-cm-string-color: var(--red-7); + --text-progress-bar-color: var(--black-color); --user-menu-icon-color: var(--white-color); diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css index 5f83a6583..097b31385 100644 --- a/app/assets/css/vendor-override.css +++ b/app/assets/css/vendor-override.css @@ -154,50 +154,6 @@ code { background-color: var(--bg-table-selected-color); } -.CodeMirror-gutters { - background: var(--bg-codemirror-gutters-color); - border-right: 0px; -} - -.CodeMirror-linenumber { - text-align: left; -} - -.CodeMirror pre.CodeMirror-line, -.CodeMirror pre.CodeMirror-line-like { - padding: 0 20px; -} - -.CodeMirror { - background: var(--bg-codemirror-color); - color: var(--text-codemirror-color); - border-radius: 8px; -} - -.CodeMirror-selected { - background: var(--bg-codemirror-selected-color) !important; -} - -.CodeMirror-cursor { - border-left: 1px solid var(--border-codemirror-cursor-color); -} - -.cm-s-default .cm-atom { - color: var(--text-cm-default-color); -} - -.cm-s-default .cm-meta { - color: var(--text-cm-meta-color); -} - -.cm-s-default .cm-string { - color: var(--text-cm-string-color); -} - -.cm-s-default .cm-number { - color: var(--text-cm-number-color); -} - .dropdown-menu { background: var(--bg-dropdown-menu-color); border-radius: 8px; diff --git a/app/kubernetes/components/yaml-inspector/yamlInspector.html b/app/kubernetes/components/yaml-inspector/yamlInspector.html index f424819f0..e33114ace 100644 --- a/app/kubernetes/components/yaml-inspector/yamlInspector.html +++ b/app/kubernetes/components/yaml-inspector/yamlInspector.html @@ -6,6 +6,7 @@ placeholder="# Define or paste the content of your manifest here" read-only="true" hide-title="true" + height="{{ $ctrl.expanded ? '800px' : '500px' }}" >
diff --git a/app/kubernetes/components/yaml-inspector/yamlInspectorController.js b/app/kubernetes/components/yaml-inspector/yamlInspectorController.js index dd5986e10..cd2bf00c8 100644 --- a/app/kubernetes/components/yaml-inspector/yamlInspectorController.js +++ b/app/kubernetes/components/yaml-inspector/yamlInspectorController.js @@ -33,9 +33,6 @@ class KubernetesYamlInspectorController { } toggleYAMLInspectorExpansion() { - let selector = 'kubernetes-yaml-inspector code-editor > div.CodeMirror'; - let height = this.expanded ? '500px' : '80vh'; - $(selector).css({ height: height }); this.expanded = !this.expanded; } diff --git a/app/portainer/components/code-editor/code-editor.html b/app/portainer/components/code-editor/code-editor.html index 33d43931b..9347d7c8f 100644 --- a/app/portainer/components/code-editor/code-editor.html +++ b/app/portainer/components/code-editor/code-editor.html @@ -5,4 +5,5 @@ readonly="$ctrl.readOnly" on-change="($ctrl.handleChange)" value="$ctrl.value" + height="$ctrl.height || undefined" > diff --git a/app/portainer/components/code-editor/code-editor.js b/app/portainer/components/code-editor/code-editor.js index 788aede3a..a4347a0a6 100644 --- a/app/portainer/components/code-editor/code-editor.js +++ b/app/portainer/components/code-editor/code-editor.js @@ -10,5 +10,6 @@ angular.module('portainer.app').component('codeEditor', { readOnly: '<', onChange: '<', value: '<', + height: '@', }, }); diff --git a/app/portainer/components/form-components/web-editor-form/index.js b/app/portainer/components/form-components/web-editor-form/index.js index b82a7f9ac..5fa476e61 100644 --- a/app/portainer/components/form-components/web-editor-form/index.js +++ b/app/portainer/components/form-components/web-editor-form/index.js @@ -12,6 +12,7 @@ export const webEditorForm = { readOnly: '<', onChange: '<', hideTitle: '<', + height: '@', }, transclude: { diff --git a/app/portainer/components/form-components/web-editor-form/web-editor-form.html b/app/portainer/components/form-components/web-editor-form/web-editor-form.html index b694a5f77..9dd875abf 100644 --- a/app/portainer/components/form-components/web-editor-form/web-editor-form.html +++ b/app/portainer/components/form-components/web-editor-form/web-editor-form.html @@ -47,6 +47,7 @@ yml="$ctrl.yml" value="$ctrl.value" on-change="($ctrl.onChange)" + height="{{ $ctrl.height }}" >
diff --git a/app/react/components/CodeEditor.module.css b/app/react/components/CodeEditor.module.css new file mode 100644 index 000000000..3cb2df907 --- /dev/null +++ b/app/react/components/CodeEditor.module.css @@ -0,0 +1,72 @@ +.root { + --text-cm-default-color: var(--blue-1); + --text-cm-meta-color: var(--black-color); + --text-cm-string-color: var(--red-3); + --text-cm-number-color: var(--green-1); + --text-cm-keyword-color: var(--ui-blue-dark-9); + --text-codemirror-color: var(--black-color); + --bg-codemirror-color: var(--white-color); + --bg-codemirror-gutters-color: var(--grey-17); + --bg-codemirror-selected-color: var(--grey-22); + --border-codemirror-cursor-color: var(--black-color); +} + +:global([theme='dark']) .root { + --text-cm-default-color: var(--blue-10); + --text-cm-meta-color: var(--white-color); + --text-cm-string-color: var(--red-5); + --text-cm-number-color: var(--green-2); + --text-cm-keyword-color: var(--ui-purple-6); + --text-codemirror-color: var(--white-color); + --bg-codemirror-color: var(--grey-2); + --bg-codemirror-gutters-color: var(--grey-3); + --bg-codemirror-selected-color: var(--grey-3); + --border-codemirror-cursor-color: var(--white-color); +} + +:global([theme='highcontrast']) .root { + --text-cm-default-color: var(--blue-9); + --text-cm-meta-color: var(--white-color); + --text-cm-string-color: var(--red-7); + --text-cm-number-color: var(--green-2); + --text-cm-keyword-color: var(--ui-purple-6); + --text-codemirror-color: var(--white-color); + --bg-codemirror-color: var(--black-color); + --bg-codemirror-gutters-color: var(--ui-gray-warm-11); + --bg-codemirror-selected-color: var(--grey-3); + --border-codemirror-cursor-color: var(--white-color); +} + +.root :global(.cm-editor .cm-gutters) { + border-right: 0px; +} + +.root :global(.cm-editor .cm-gutters .cm-lineNumbers .cm-gutterElement) { + text-align: left; +} + +.root :global(.cm-editor), +.root :global(.cm-editor .cm-scroller) { + border-radius: 8px; +} + +/* Search Panel */ +/* Ideally we would use a react component for that, but this is the easy solution for onw */ + +.root :global(.cm-panels.cm-panels-bottom) { + background-color: var(--bg-codemirror-gutters-color); + border-top-color: transparent; + color: var(--text-codemirror-color); +} + +.root :global(.cm-button) { + @apply bg-blue-8; + color: var(--text-codemirror-color); + background-image: none; +} + +.root :global(.cm-textfield) { + border: 1px solid var(--border-form-control-color); + background-color: var(--bg-inputbox); + color: var(--text-form-control-color); +} diff --git a/app/react/components/CodeEditor.tsx b/app/react/components/CodeEditor.tsx index f0ad8255c..8d77845aa 100644 --- a/app/react/components/CodeEditor.tsx +++ b/app/react/components/CodeEditor.tsx @@ -1,7 +1,11 @@ import CodeMirror from '@uiw/react-codemirror'; -import { StreamLanguage } from '@codemirror/language'; +import { StreamLanguage, LanguageSupport } from '@codemirror/language'; import { yaml } from '@codemirror/legacy-modes/mode/yaml'; import { useMemo } from 'react'; +import { createTheme } from '@uiw/codemirror-themes'; +import { tags as highlightTags } from '@lezer/highlight'; + +import styles from './CodeEditor.module.css'; interface Props { id: string; @@ -13,6 +17,30 @@ interface Props { height?: string; } +const theme = createTheme({ + theme: 'light', + settings: { + background: 'var(--bg-codemirror-color)', + foreground: 'var(--text-codemirror-color)', + caret: 'var(--border-codemirror-cursor-color)', + selection: 'var(--bg-codemirror-selected-color)', + selectionMatch: 'var(--bg-codemirror-selected-color)', + gutterBackground: 'var(--bg-codemirror-gutters-color)', + }, + styles: [ + { tag: highlightTags.atom, color: 'var(--text-cm-default-color)' }, + { tag: highlightTags.meta, color: 'var(--text-cm-meta-color)' }, + { + tag: [highlightTags.string, highlightTags.special(highlightTags.brace)], + color: 'var(--text-cm-string-color)', + }, + { tag: highlightTags.number, color: 'var(--text-cm-number-color)' }, + { tag: highlightTags.keyword, color: 'var(--text-cm-keyword-color)' }, + ], +}); + +const yamlLanguage = new LanguageSupport(StreamLanguage.define(yaml)); + export function CodeEditor({ id, onChange, @@ -22,13 +50,12 @@ export function CodeEditor({ height = '500px', yaml: isYaml, }: Props) { - const extensions = useMemo( - () => (isYaml ? [StreamLanguage.define(yaml)] : []), - [isYaml] - ); + const extensions = useMemo(() => (isYaml ? [yamlLanguage] : []), [isYaml]); return ( Date: Mon, 6 Mar 2023 22:25:04 +0200 Subject: [PATCH 12/55] feat(edge): associate edge env to meta fields [EE-3209] (#8551) * refactor(edge/groups): load edge groups in selector fix(edge/stacks): remove double groups title * feat(edge): supply meta fields to edge script [EE-5043] * feat(edge): auto assign aeec envs to groups and tags [EE-5043] fix [EE-5043] fix(envs): fix global key test * fix(edge/groups): save group type * refactor(edge/devices): move loading of devices to table * refactor(tags): select paramter for query * feat(edge/devices): show meta fields * refactor(home): simplify filter * feat(edge/devices): filter by meta fields * refactor(edge/devices): break filter and loading hook --- .../editEdgeStackForm.html | 16 +-- app/edge/components/group-form/groupForm.html | 2 +- .../group-form/groupFormController.js | 5 + app/edge/react/components/index.ts | 9 +- .../createEdgeGroupViewController.js | 29 +++--- .../editEdgeGroupViewController.js | 29 +++--- .../create-edge-stack-view.html | 14 +-- app/portainer/react/components/index.ts | 1 + app/portainer/tags/queries.ts | 8 +- .../components/TagSelector/TagSelector.tsx | 10 +- .../form-components/PortainerSelect.tsx | 31 ++++-- .../EdgeScriptForm/EdgeScriptForm.tsx | 6 ++ .../EdgeScriptSettingsFieldset.tsx | 25 +++++ .../edge/components/EdgeScriptForm/scripts.ts | 83 ++++++++++------ .../edge/components/EdgeScriptForm/types.ts | 9 ++ .../WaitingRoomView/Datatable/Datatable.tsx | 14 ++- .../WaitingRoomView/Datatable/Filter.tsx | 50 ++++++++++ .../WaitingRoomView/Datatable/columns.ts | 37 ++++++- .../WaitingRoomView/Datatable/filter-store.ts | 35 +++++++ .../Datatable/useEnvironments.ts | 74 ++++++++++++++ .../WaitingRoomView/WaitingRoomView.tsx | 14 +-- .../edge-devices/WaitingRoomView/types.ts | 7 ++ .../edge/edge-groups/queries/useEdgeGroups.ts | 13 ++- .../components/EdgeGroupsSelector.tsx | 68 ++++++++++++- .../EnvironmentItem/EnvironmentItem.tsx | 22 +++-- .../EnvironmentList/EnvironmentList.tsx | 97 +++++++------------ .../EnvironmentListFilters.tsx | 55 ++++++----- .../EnvironmentList/HomepageFilter.tsx | 25 ++--- .../EnvironmentList/SortbySelector.tsx | 13 ++- .../AutomaticEdgeEnvCreation.tsx | 1 + .../environment-groups/queries.ts | 8 +- .../shared/MetadataFieldset/GroupsField.tsx | 7 +- 32 files changed, 554 insertions(+), 263 deletions(-) create mode 100644 app/react/edge/edge-devices/WaitingRoomView/Datatable/Filter.tsx create mode 100644 app/react/edge/edge-devices/WaitingRoomView/Datatable/filter-store.ts create mode 100644 app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts create mode 100644 app/react/edge/edge-devices/WaitingRoomView/types.ts diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html index 942babdce..436f393c7 100644 --- a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html +++ b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html @@ -1,19 +1,5 @@ -
Edge Groups
-
-
- -
- -

- There are no available deployment types when there is more than one type of environment in your edge group - selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type. -

-

- Edge groups with kubernetes environments no longer support compose deployment types in Portainer. Please select - edge groups that only have docker environments when using compose deployment types. -

-
+ +
diff --git a/app/edge/components/group-form/groupFormController.js b/app/edge/components/group-form/groupFormController.js index 253c7ea66..7086570a7 100644 --- a/app/edge/components/group-form/groupFormController.js +++ b/app/edge/components/group-form/groupFormController.js @@ -37,6 +37,7 @@ export class EdgeGroupFormController { this.onChangeDynamic = this.onChangeDynamic.bind(this); this.onChangeModel = this.onChangeModel.bind(this); this.onChangePartialMatch = this.onChangePartialMatch.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); $scope.$watch( () => this.model, @@ -118,6 +119,10 @@ export class EdgeGroupFormController { }); } + handleSubmit() { + this.formAction(this.model); + } + $onInit() { this.getTags(); } diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index 3eee3f259..fb877f251 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -12,7 +12,13 @@ export const componentsModule = angular .module('portainer.edge.react.components', []) .component( 'edgeGroupsSelector', - r2a(EdgeGroupsSelector, ['items', 'onChange', 'value']) + r2a(withReactQuery(EdgeGroupsSelector), [ + 'onChange', + 'value', + 'error', + 'horizontal', + 'isGroupVisible', + ]) ) .component( 'edgeScriptForm', @@ -21,6 +27,7 @@ export const componentsModule = angular 'commands', 'isNomadTokenVisible', 'asyncMode', + 'showMetaFields', ]) ) .component( diff --git a/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js b/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js index a1172b836..c82a63f1d 100644 --- a/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js +++ b/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js @@ -21,7 +21,6 @@ export class CreateEdgeGroupController { }; this.createGroup = this.createGroup.bind(this); - this.createGroupAsync = this.createGroupAsync.bind(this); } async $onInit() { @@ -31,20 +30,18 @@ export class CreateEdgeGroupController { this.state.loaded = true; } - createGroup() { - return this.$async(this.createGroupAsync); - } - - async createGroupAsync() { - this.state.actionInProgress = true; - try { - await this.EdgeGroupService.create(this.model); - this.Notifications.success('Success', 'Edge group successfully created'); - this.$state.go('edge.groups'); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to create edge group'); - } finally { - this.state.actionInProgress = false; - } + async createGroup(model) { + return this.$async(async () => { + this.state.actionInProgress = true; + try { + await this.EdgeGroupService.create(model); + this.Notifications.success('Success', 'Edge group successfully created'); + this.$state.go('edge.groups'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to create edge group'); + } finally { + this.state.actionInProgress = false; + } + }); } } diff --git a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js b/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js index feb06b2c2..3838206ca 100644 --- a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js +++ b/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js @@ -13,7 +13,6 @@ export class EditEdgeGroupController { }; this.updateGroup = this.updateGroup.bind(this); - this.updateGroupAsync = this.updateGroupAsync.bind(this); } async $onInit() { @@ -28,20 +27,18 @@ export class EditEdgeGroupController { this.state.loaded = true; } - updateGroup() { - return this.$async(this.updateGroupAsync); - } - - async updateGroupAsync() { - this.state.actionInProgress = true; - try { - await this.EdgeGroupService.update(this.model); - this.Notifications.success('Success', 'Edge group successfully updated'); - this.$state.go('edge.groups'); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to update edge group'); - } finally { - this.state.actionInProgress = false; - } + updateGroup(group) { + return this.$async(async () => { + this.state.actionInProgress = true; + try { + await this.EdgeGroupService.update(group); + this.Notifications.success('Success', 'Edge group successfully updated'); + this.$state.go('edge.groups'); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to update edge group'); + } finally { + this.state.actionInProgress = false; + } + }); } } diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html index ecfa4dc84..7f1b730bb 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html @@ -39,19 +39,7 @@
-
Edge Groups
-
-
- -
-
- No Edge groups are available. Head over to the Edge groups view to create one. -
-

- There are no available deployment types when there is more than one type of environment in your edge - group selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type. -

-
+ [...tagKeys.all, id] as const, }; -export function useTags(select?: (tags: Tag[]) => T[]) { - const { data, isLoading } = useQuery(tagKeys.all, () => getTags(), { +export function useTags({ + select, +}: { select?: (tags: Tag[]) => T } = {}) { + return useQuery(tagKeys.all, () => getTags(), { staleTime: 50, select, ...withError('Failed to retrieve tags'), }); - - return { tags: data, isLoading }; } export function useCreateTagMutation() { diff --git a/app/react/components/TagSelector/TagSelector.tsx b/app/react/components/TagSelector/TagSelector.tsx index 79ee52fca..2c48e2908 100644 --- a/app/react/components/TagSelector/TagSelector.tsx +++ b/app/react/components/TagSelector/TagSelector.tsx @@ -22,17 +22,17 @@ interface Option { export function TagSelector({ value, allowCreate = false, onChange }: Props) { // change the struct because react-select has a bug with Creatable (https://github.com/JedWatson/react-select/issues/3417#issuecomment-461868989) - const tagsQuery = useTags((tags) => - tags.map((opt) => ({ label: opt.Name, value: opt.ID })) - ); + const tagsQuery = useTags({ + select: (tags) => tags?.map((opt) => ({ label: opt.Name, value: opt.ID })), + }); const createTagMutation = useCreateTagMutation(); - if (!tagsQuery.tags) { + if (!tagsQuery.data) { return null; } - const { tags } = tagsQuery; + const { data: tags } = tagsQuery; const selectedTags = _.compact( value.map((id) => tags.find((tag) => tag.value === id)) diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx index fc02a700c..fa4e1128c 100644 --- a/app/react/components/form-components/PortainerSelect.tsx +++ b/app/react/components/form-components/PortainerSelect.tsx @@ -1,4 +1,8 @@ -import { OptionsOrGroups } from 'react-select'; +import { + GroupBase, + OptionsOrGroups, + SelectComponentsConfig, +} from 'react-select'; import _ from 'lodash'; import { AutomationTestingProps } from '@/types'; @@ -10,9 +14,10 @@ export interface Option { label: string; } -type Group = { label: string; options: Option[] }; - -type Options = OptionsOrGroups, Group>; +type Options = OptionsOrGroups< + Option, + GroupBase> +>; interface SharedProps extends AutomationTestingProps { name?: string; @@ -28,6 +33,11 @@ interface MultiProps extends SharedProps { onChange(value: readonly TValue[]): void; options: Options; isMulti: true; + components?: SelectComponentsConfig< + Option, + true, + GroupBase> + >; } interface SingleProps extends SharedProps { @@ -35,6 +45,11 @@ interface SingleProps extends SharedProps { onChange(value: TValue | null): void; options: Options; isMulti?: never; + components?: SelectComponentsConfig< + Option, + false, + GroupBase> + >; } type Props = MultiProps | SingleProps; @@ -66,6 +81,7 @@ export function SingleSelect({ placeholder, isClearable, bindToBody, + components, }: SingleProps) { const selectedValue = value || (typeof value === 'number' && value === 0) @@ -86,6 +102,7 @@ export function SingleSelect({ placeholder={placeholder} isDisabled={disabled} menuPortalTarget={bindToBody ? document.body : undefined} + components={components} /> ); } @@ -124,6 +141,7 @@ export function MultiSelect({ disabled, isClearable, bindToBody, + components, }: Omit, 'isMulti'>) { const selectedOptions = findSelectedOptions(options, value); return ( @@ -142,12 +160,13 @@ export function MultiSelect({ placeholder={placeholder} isDisabled={disabled} menuPortalTarget={bindToBody ? document.body : undefined} + components={components} /> ); } function isGroup( - option: Option | Group -): option is Group { + option: Option | GroupBase> +): option is GroupBase> { return 'options' in option; } diff --git a/app/react/edge/components/EdgeScriptForm/EdgeScriptForm.tsx b/app/react/edge/components/EdgeScriptForm/EdgeScriptForm.tsx index d143951e7..cc8e2bc77 100644 --- a/app/react/edge/components/EdgeScriptForm/EdgeScriptForm.tsx +++ b/app/react/edge/components/EdgeScriptForm/EdgeScriptForm.tsx @@ -16,6 +16,9 @@ const edgePropertiesFormInitialValues: ScriptFormValues = { nomadToken: '', authEnabled: true, tlsEnabled: false, + edgeGroupsIds: [], + group: 0, + tagsIds: [], }; interface Props { @@ -23,6 +26,7 @@ interface Props { commands: CommandTab[] | Partial>; isNomadTokenVisible?: boolean; asyncMode?: boolean; + showMetaFields?: boolean; } export function EdgeScriptForm({ @@ -30,6 +34,7 @@ export function EdgeScriptForm({ commands, isNomadTokenVisible, asyncMode, + showMetaFields, children, }: PropsWithChildren) { const showOsSelector = !(commands instanceof Array); @@ -50,6 +55,7 @@ export function EdgeScriptForm({ isNomadTokenVisible && values.platform === 'nomad' } hideIdGetter={edgeInfo.id !== undefined} + showMetaFields={showMetaFields} />
{showOsSelector && ( diff --git a/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx b/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx index ceee5c746..1b1c3adbe 100644 --- a/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx +++ b/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx @@ -1,9 +1,14 @@ import { useFormikContext, Field } from 'formik'; +import { GroupField } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/MetadataFieldset/GroupsField'; + import { FormControl } from '@@/form-components/FormControl'; import { Input } from '@@/form-components/Input'; import { SwitchField } from '@@/form-components/SwitchField'; import { TextTip } from '@@/Tip/TextTip'; +import { TagSelector } from '@@/TagSelector'; + +import { EdgeGroupsSelector } from '../../edge-stacks/components/EdgeGroupsSelector'; import { NomadTokenField } from './NomadTokenField'; import { ScriptFormValues } from './types'; @@ -11,16 +16,36 @@ import { ScriptFormValues } from './types'; interface Props { isNomadTokenVisible?: boolean; hideIdGetter?: boolean; + showMetaFields?: boolean; } export function EdgeScriptSettingsFieldset({ isNomadTokenVisible, hideIdGetter, + showMetaFields, }: Props) { const { values, setFieldValue } = useFormikContext(); return ( <> + {showMetaFields && ( + <> + + + setFieldValue('edgeGroupsIds', value)} + isGroupVisible={(group) => !group.Dynamic} + horizontal + /> + + setFieldValue('tagsIds', value)} + /> + + )} + {!hideIdGetter && ( <> = { }, } as const; -function buildDockerEnvVars(envVars: string, defaultVars: string[]) { - const vars = defaultVars.concat( - envVars.split(',').filter((s) => s.length > 0) - ); - - return vars.map((s) => `-e ${s}`).join(' \\\n '); -} - export function buildLinuxStandaloneCommand( agentVersion: string, edgeKey: string, @@ -70,16 +62,16 @@ export function buildLinuxStandaloneCommand( ) { const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties; - const env = buildDockerEnvVars( - envVars, - buildDefaultEnvVars( + const env = buildDockerEnvVars(envVars, [ + ...buildDefaultDockerEnvVars( edgeKey, allowSelfSignedCertificates, !edgeIdGenerator ? edgeId : undefined, agentSecret, useAsyncMode - ) - ); + ), + ...metaEnvVars(properties), + ]); return `${ edgeIdGenerator ? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n` : '' @@ -106,16 +98,16 @@ export function buildWindowsStandaloneCommand( ) { const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties; - const env = buildDockerEnvVars( - envVars, - buildDefaultEnvVars( + const env = buildDockerEnvVars(envVars, [ + ...buildDefaultDockerEnvVars( edgeKey, allowSelfSignedCertificates, edgeIdGenerator ? '$Env:PORTAINER_EDGE_ID' : edgeId, agentSecret, useAsyncMode - ) - ); + ), + ...metaEnvVars(properties), + ]); return `${ edgeIdGenerator @@ -144,7 +136,7 @@ export function buildLinuxSwarmCommand( const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties; const env = buildDockerEnvVars(envVars, [ - ...buildDefaultEnvVars( + ...buildDefaultDockerEnvVars( edgeKey, allowSelfSignedCertificates, !edgeIdGenerator ? edgeId : undefined, @@ -152,6 +144,7 @@ export function buildLinuxSwarmCommand( useAsyncMode ), 'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent', + ...metaEnvVars(properties), ]); return `${ @@ -186,7 +179,7 @@ export function buildWindowsSwarmCommand( const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties; const env = buildDockerEnvVars(envVars, [ - ...buildDefaultEnvVars( + ...buildDefaultDockerEnvVars( edgeKey, allowSelfSignedCertificates, edgeIdGenerator ? '$Env:PORTAINER_EDGE_ID' : edgeId, @@ -194,6 +187,7 @@ export function buildWindowsSwarmCommand( useAsyncMode ), 'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent', + ...metaEnvVars(properties), ]); return `${ @@ -229,17 +223,18 @@ export function buildLinuxKubernetesCommand( const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties; const agentShortVersion = getAgentShortVersion(agentVersion); - let envVarsTrimmed = envVars.trim(); - if (useAsyncMode) { - envVarsTrimmed += `EDGE_ASYNC=1`; - } + const allEnvVars = buildEnvVars( + envVars, + _.compact([useAsyncMode && 'EDGE_ASYNC=1', ...metaEnvVars(properties)]) + ); + const idEnvVar = edgeIdGenerator ? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n` : ''; const edgeIdVar = !edgeIdGenerator && edgeId ? edgeId : '$PORTAINER_EDGE_ID'; const selfSigned = allowSelfSignedCertificates ? '1' : '0'; - return `${idEnvVar}curl https://downloads.portainer.io/ee${agentShortVersion}/portainer-edge-agent-setup.sh | bash -s -- "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${agentSecret}" "${envVarsTrimmed}"`; + return `${idEnvVar}curl https://downloads.portainer.io/ee${agentShortVersion}/portainer-edge-agent-setup.sh | bash -s -- "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${agentSecret}" "${allEnvVars}"`; } export function buildLinuxNomadCommand( @@ -259,10 +254,11 @@ export function buildLinuxNomadCommand( } = properties; const agentShortVersion = getAgentShortVersion(agentVersion); - let envVarsTrimmed = envVars.trim(); - if (useAsyncMode) { - envVarsTrimmed += `EDGE_ASYNC=1`; - } + + const allEnvVars = buildEnvVars( + envVars, + _.compact([useAsyncMode && 'EDGE_ASYNC=1', ...metaEnvVars(properties)]) + ); const selfSigned = allowSelfSignedCertificates ? '1' : '0'; const idEnvVar = edgeIdGenerator @@ -270,10 +266,16 @@ export function buildLinuxNomadCommand( : ''; const edgeIdVar = !edgeIdGenerator && edgeId ? edgeId : '$PORTAINER_EDGE_ID'; - return `${idEnvVar}curl https://downloads.portainer.io/ee${agentShortVersion}/portainer-edge-agent-nomad-setup.sh | bash -s -- "${nomadToken}" "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${envVarsTrimmed}" "${agentSecret}" "${tlsEnabled}"`; + return `${idEnvVar}curl https://downloads.portainer.io/ee${agentShortVersion}/portainer-edge-agent-nomad-setup.sh | bash -s -- "${nomadToken}" "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${allEnvVars}" "${agentSecret}" "${tlsEnabled}"`; } -function buildDefaultEnvVars( +function buildDockerEnvVars(envVars: string, moreVars: string[]) { + const vars = moreVars.concat(envVars.split(',').filter((s) => s.length > 0)); + + return vars.map((s) => `-e ${s}`).join(' \\\n '); +} + +function buildDefaultDockerEnvVars( edgeKey: string, allowSelfSignedCerts: boolean, edgeId = '$PORTAINER_EDGE_ID', @@ -289,3 +291,22 @@ function buildDefaultEnvVars( useAsyncMode ? 'EDGE_ASYNC=1' : '', ]); } + +const ENV_VAR_SEPARATOR = ','; +const VAR_LIST_SEPARATOR = ':'; +function buildEnvVars(envVars: string, moreVars: string[]) { + return _.compact([envVars.trim(), ...moreVars]).join(ENV_VAR_SEPARATOR); +} + +function metaEnvVars({ + edgeGroupsIds, + group, + tagsIds, +}: Pick) { + return _.compact([ + edgeGroupsIds.length && + `EDGE_GROUPS=${edgeGroupsIds.join(VAR_LIST_SEPARATOR)}`, + group && `PORTAINER_GROUP=${group}`, + tagsIds.length && `PORTAINER_TAGS=${tagsIds.join(VAR_LIST_SEPARATOR)}`, + ]); +} diff --git a/app/react/edge/components/EdgeScriptForm/types.ts b/app/react/edge/components/EdgeScriptForm/types.ts index 2d6817922..f7c8f86a2 100644 --- a/app/react/edge/components/EdgeScriptForm/types.ts +++ b/app/react/edge/components/EdgeScriptForm/types.ts @@ -1,3 +1,8 @@ +import { TagId } from '@/portainer/tags/types'; +import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; + +import { EdgeGroup } from '../../edge-groups/types'; + export type Platform = 'standalone' | 'swarm' | 'k8s' | 'nomad'; export type OS = 'win' | 'linux'; @@ -13,6 +18,10 @@ export interface ScriptFormValues { platform: Platform; edgeIdGenerator?: string; + + group: EnvironmentGroupId; + edgeGroupsIds: Array; + tagsIds: Array; } export interface EdgeInfo { diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx index db5daa52e..d622de079 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx @@ -12,27 +12,24 @@ import { useSearchBarState } from '@@/datatables/SearchBar'; import { useAssociateDeviceMutation, useLicenseOverused } from '../queries'; import { columns } from './columns'; +import { Filter } from './Filter'; +import { useEnvironments } from './useEnvironments'; const storageKey = 'edge-devices-waiting-room'; const settingsStore = createPersistedStore(storageKey, 'Name'); -interface Props { - devices: Environment[]; - isLoading: boolean; - totalCount: number; -} - -export function Datatable({ devices, isLoading, totalCount }: Props) { +export function Datatable() { const associateMutation = useAssociateDeviceMutation(); const licenseOverused = useLicenseOverused(); const settings = useStore(settingsStore); const [search, setSearch] = useSearchBarState(storageKey); + const { data: environments, totalCount, isLoading } = useEnvironments(); return ( } /> ); diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Filter.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Filter.tsx new file mode 100644 index 000000000..1ba91a723 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Filter.tsx @@ -0,0 +1,50 @@ +import { HomepageFilter } from '@/react/portainer/HomeView/EnvironmentList/HomepageFilter'; +import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; +import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups'; +import { useTags } from '@/portainer/tags/queries'; + +import { useFilterStore } from './filter-store'; + +export function Filter() { + const edgeGroupsQuery = useEdgeGroups(); + const groupsQuery = useGroups(); + const tagsQuery = useTags(); + + const filterStore = useFilterStore(); + + if (!edgeGroupsQuery.data || !groupsQuery.data || !tagsQuery.data) { + return null; + } + + return ( +
+ filterStore.setEdgeGroups(f)} + placeHolder="Edge groups" + value={filterStore.edgeGroups} + filterOptions={edgeGroupsQuery.data.map((g) => ({ + label: g.Name, + value: g.Id, + }))} + /> + filterStore.setGroups(f)} + placeHolder="Group" + value={filterStore.groups} + filterOptions={groupsQuery.data.map((g) => ({ + label: g.Name, + value: g.Id, + }))} + /> + filterStore.setTags(f)} + placeHolder="Tags" + value={filterStore.tags} + filterOptions={tagsQuery.data.map((g) => ({ + label: g.Name, + value: g.ID, + }))} + /> +
+ ); +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts index 8a5078247..0c2722761 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts @@ -1,8 +1,8 @@ -import { Column } from 'react-table'; +import { CellProps, Column } from 'react-table'; -import { Environment } from '@/react/portainer/environments/types'; +import { WaitingRoomEnvironment } from '../types'; -export const columns: readonly Column[] = [ +export const columns: readonly Column[] = [ { Header: 'Name', accessor: (row) => row.Name, @@ -21,4 +21,35 @@ export const columns: readonly Column[] = [ canHide: false, sortType: 'string', }, + { + Header: 'Edge Groups', + accessor: (row) => row.EdgeGroups || [], + Cell: ({ value }: CellProps) => + value.join(', ') || '-', + id: 'edge-groups', + disableFilters: true, + Filter: () => null, + canHide: false, + sortType: 'string', + }, + { + Header: 'Group', + accessor: (row) => row.Group || '-', + id: 'group', + disableFilters: true, + Filter: () => null, + canHide: false, + sortType: 'string', + }, + { + Header: 'Tags', + accessor: (row) => row.Tags || [], + Cell: ({ value }: CellProps) => + value.join(', ') || '-', + id: 'tags', + disableFilters: true, + Filter: () => null, + canHide: false, + sortType: 'string', + }, ] as const; diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/filter-store.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/filter-store.ts new file mode 100644 index 000000000..feeeae6d7 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/filter-store.ts @@ -0,0 +1,35 @@ +import createStore from 'zustand'; +import { persist } from 'zustand/middleware'; + +import { keyBuilder } from '@/react/hooks/useLocalStorage'; + +interface TableFiltersStore { + groups: number[]; + setGroups(value: number[]): void; + edgeGroups: number[]; + setEdgeGroups(value: number[]): void; + tags: number[]; + setTags(value: number[]): void; +} + +export const useFilterStore = createStore()( + persist( + (set) => ({ + edgeGroups: [], + setEdgeGroups(edgeGroups: number[]) { + set({ edgeGroups }); + }, + groups: [], + setGroups(groups: number[]) { + set({ groups }); + }, + tags: [], + setTags(tags: number[]) { + set({ tags }); + }, + }), + { + name: keyBuilder('edge-devices-meta-filters'), + } + ) +); diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts new file mode 100644 index 000000000..6d899fc2b --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts @@ -0,0 +1,74 @@ +import _ from 'lodash'; + +import { useTags } from '@/portainer/tags/queries'; +import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups'; +import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; +import { useEnvironmentList } from '@/react/portainer/environments/queries'; +import { EdgeTypes } from '@/react/portainer/environments/types'; + +import { WaitingRoomEnvironment } from '../types'; + +import { useFilterStore } from './filter-store'; + +export function useEnvironments() { + const filterStore = useFilterStore(); + const edgeGroupsQuery = useEdgeGroups(); + + const filterByEnvironmentsIds = filterStore.edgeGroups.length + ? _.compact( + filterStore.edgeGroups.flatMap( + (groupId) => + edgeGroupsQuery.data?.find((g) => g.Id === groupId)?.Endpoints + ) + ) + : undefined; + + const environmentsQuery = useEnvironmentList({ + edgeDeviceUntrusted: true, + excludeSnapshots: true, + types: EdgeTypes, + tagIds: filterStore.tags.length ? filterStore.tags : undefined, + groupIds: filterStore.groups.length ? filterStore.groups : undefined, + endpointIds: filterByEnvironmentsIds, + }); + + const groupsQuery = useGroups({ + select: (groups) => + Object.fromEntries(groups.map((g) => [g.Id, g.Name] as const)), + }); + const environmentEdgeGroupsQuery = useEdgeGroups({ + select: (groups) => + _.groupBy( + groups.flatMap((group) => { + const envs = group.Endpoints; + return envs.map((id) => ({ id, group: group.Name })); + }), + (env) => env.id + ), + }); + const tagsQuery = useTags({ + select: (tags) => + Object.fromEntries(tags.map((tag) => [tag.ID, tag.Name] as const)), + }); + + const envs: Array = + environmentsQuery.environments.map((env) => ({ + ...env, + Group: groupsQuery.data?.[env.GroupId] || '', + EdgeGroups: + environmentEdgeGroupsQuery.data?.[env.Id]?.map((env) => env.group) || + [], + Tags: + _.compact(env.TagIds?.map((tagId) => tagsQuery.data?.[tagId])) || [], + })); + + return { + data: envs, + isLoading: + environmentsQuery.isLoading || + groupsQuery.isLoading || + environmentEdgeGroupsQuery.isLoading || + tagsQuery.isLoading, + totalCount: environmentsQuery.totalCount, + }; +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx index cce80f494..396fb0a3a 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx @@ -1,5 +1,3 @@ -import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList'; -import { EdgeTypes } from '@/react/portainer/environments/types'; import { withLimitToBE } from '@/react/hooks/useLimitToBE'; import { InformationPanel } from '@@/InformationPanel'; @@ -11,12 +9,6 @@ import { Datatable } from './Datatable'; export default withLimitToBE(WaitingRoomView); function WaitingRoomView() { - const { environments, isLoading, totalCount } = useEnvironmentList({ - edgeDeviceUntrusted: true, - excludeSnapshots: true, - types: EdgeTypes, - }); - return ( <> - + ); } diff --git a/app/react/edge/edge-devices/WaitingRoomView/types.ts b/app/react/edge/edge-devices/WaitingRoomView/types.ts new file mode 100644 index 000000000..484313732 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/types.ts @@ -0,0 +1,7 @@ +import { Environment } from '@/react/portainer/environments/types'; + +export type WaitingRoomEnvironment = Environment & { + EdgeGroups: string[]; + Tags: string[]; + Group: string; +}; diff --git a/app/react/edge/edge-groups/queries/useEdgeGroups.ts b/app/react/edge/edge-groups/queries/useEdgeGroups.ts index 8bc1548c6..d2a256309 100644 --- a/app/react/edge/edge-groups/queries/useEdgeGroups.ts +++ b/app/react/edge/edge-groups/queries/useEdgeGroups.ts @@ -1,22 +1,29 @@ import { useQuery } from 'react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentType } from '@/react/portainer/environments/types'; import { EdgeGroup } from '../types'; +interface EdgeGroupListItemResponse extends EdgeGroup { + EndpointTypes: Array; +} + async function getEdgeGroups() { try { - const { data } = await axios.get('/edge_groups'); + const { data } = await axios.get( + '/edge_groups' + ); return data; } catch (err) { throw parseAxiosError(err as Error, 'Failed fetching edge groups'); } } -export function useEdgeGroups({ +export function useEdgeGroups({ select, }: { - select?: (groups: EdgeGroup[]) => T; + select?: (groups: EdgeGroupListItemResponse[]) => T; } = {}) { return useQuery(['edge', 'groups'], getEdgeGroups, { select }); } diff --git a/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx b/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx index 5f1147822..5d32f1b28 100644 --- a/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx +++ b/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx @@ -3,21 +3,78 @@ import _ from 'lodash'; import { EdgeGroup } from '@/react/edge/edge-groups/types'; import { Select } from '@@/form-components/ReactSelect'; +import { FormSection } from '@@/form-components/FormSection'; +import { FormError } from '@@/form-components/FormError'; +import { Link } from '@@/Link'; +import { FormControl } from '@@/form-components/FormControl'; + +import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups'; type SingleValue = EdgeGroup['Id']; interface Props { - items: EdgeGroup[]; value: SingleValue[]; onChange: (value: SingleValue[]) => void; + error?: string | string[]; + horizontal?: boolean; + isGroupVisible?(group: EdgeGroup): boolean; } -export function EdgeGroupsSelector({ items, value, onChange }: Props) { +export function EdgeGroupsSelector({ + value, + onChange, + error, + horizontal, + isGroupVisible = () => true, +}: Props) { + const selector = ( + + ); + + return horizontal ? ( + + {selector} + + ) : ( + +
+
{selector}
+ {error && ( +
+ {error} +
+ )} +
+
+ ); +} + +function InnerSelector({ + value, + onChange, + isGroupVisible, +}: { + isGroupVisible(group: EdgeGroup): boolean; + value: SingleValue[]; + onChange: (value: SingleValue[]) => void; +}) { + const edgeGroupsQuery = useEdgeGroups(); + + if (!edgeGroupsQuery.data) { + return null; + } + + const items = edgeGroupsQuery.data.filter(isGroupVisible); + const valueGroups = _.compact( value.map((id) => items.find((item) => item.Id === id)) ); - return ( + return items.length ? ( null} />{' '} - +
+ null} /> + +
); @@ -35,14 +38,14 @@ export function HomepageFilter({ value, }: Props) { return ( - onChange(option as Filter)} + onChange={(option) => onChange(option || '')} isClearable value={value} /> diff --git a/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx b/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx index 5d5f91d81..8880986d5 100644 --- a/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx +++ b/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx @@ -158,6 +158,7 @@ function EdgeKeyInfo({ commands={commands} isNomadTokenVisible asyncMode={asyncMode} + showMetaFields > diff --git a/app/react/portainer/environments/environment-groups/queries.ts b/app/react/portainer/environments/environment-groups/queries.ts index d480c95c4..9b2f937e9 100644 --- a/app/react/portainer/environments/environment-groups/queries.ts +++ b/app/react/portainer/environments/environment-groups/queries.ts @@ -5,8 +5,12 @@ import { error as notifyError } from '@/portainer/services/notifications'; import { EnvironmentGroup, EnvironmentGroupId } from './types'; import { getGroup, getGroups } from './environment-groups.service'; -export function useGroups() { - return useQuery(['environment-groups'], getGroups); +export function useGroups({ + select, +}: { select?: (group: EnvironmentGroup[]) => T } = {}) { + return useQuery(['environment-groups'], getGroups, { + select, + }); } export function useGroup( diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/MetadataFieldset/GroupsField.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/MetadataFieldset/GroupsField.tsx index 6dff2a13b..1f6b403f4 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/MetadataFieldset/GroupsField.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/MetadataFieldset/GroupsField.tsx @@ -6,9 +6,8 @@ import { EnvironmentGroupId } from '@/react/portainer/environments/environment-g import { FormControl } from '@@/form-components/FormControl'; import { Select } from '@@/form-components/Input'; -export function GroupField() { - const [fieldProps, metaProps, helpers] = - useField('meta.groupId'); +export function GroupField({ name = 'meta.groupId' }: { name?: string }) { + const [fieldProps, metaProps, helpers] = useField(name); const groupsQuery = useGroups(); if (!groupsQuery.data) { @@ -23,7 +22,7 @@ export function GroupField() { return (
-
+
-
+
diff --git a/app/kubernetes/views/configurations/create/createConfiguration.html b/app/kubernetes/views/configurations/create/createConfiguration.html index 8d905b2a1..d4a9fba40 100644 --- a/app/kubernetes/views/configurations/create/createConfiguration.html +++ b/app/kubernetes/views/configurations/create/createConfiguration.html @@ -15,7 +15,7 @@
- +
-
-
+
+
+ +
+ +
+
+
diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 89acc30ce..17f6d4916 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -4,6 +4,7 @@ import { r2a } from '@/react-tools/react2angular'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; +import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsBeTeaser'; import { Icon } from '@@/Icon'; import { ReactQueryDevtoolsWrapper } from '@@/ReactQueryDevtoolsWrapper'; @@ -188,4 +189,5 @@ export const componentsModule = angular 'value', 'height', ]) - ).name; + ) + .component('annotationsBeTeaser', r2a(AnnotationsBeTeaser, [])).name; diff --git a/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx b/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx index 5178641c9..58733891f 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx +++ b/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx @@ -116,7 +116,7 @@ export function IngressForm({
-
+
diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index 5ebe986fb..ab05117ef 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -46,8 +46,6 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment && endpoint.Status === EnvironmentStatus.Down) { throw new Error('Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.'); } - - await KubernetesNamespaceService.get(); } catch (e) { let params = {}; diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index ac2509366..3d5a8b8d8 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -6,6 +6,7 @@ import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { IngressesDatatableView } from '@/react/kubernetes/ingresses/IngressDatatable'; import { CreateIngressView } from '@/react/kubernetes/ingresses/CreateIngressView'; +import { DashboardView } from '@/react/kubernetes/DashboardView'; import { ServicesView } from '@/react/kubernetes/ServicesView'; export const viewsModule = angular @@ -24,4 +25,8 @@ export const viewsModule = angular .component( 'kubernetesIngressesCreateView', r2a(withUIRouter(withReactQuery(withCurrentUser(CreateIngressView))), []) + ) + .component( + 'kubernetesDashboardView', + r2a(withUIRouter(withReactQuery(withCurrentUser(DashboardView))), []) ).name; diff --git a/app/kubernetes/views/dashboard/dashboard.html b/app/kubernetes/views/dashboard/dashboard.html deleted file mode 100644 index a076c70a3..000000000 --- a/app/kubernetes/views/dashboard/dashboard.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - -
-
-
- -
-
-
- -
- Environment info -
-
- - - - - - - - - - - - - - - - -
Environment - {{ ctrl.endpoint.Name }} -
URL{{ ctrl.endpoint.URL | stripprotocol }}
Tags{{ ctrl.endpointTags }}
-
-
-
-
- -
-
- - - -
- -
- - - -
-
- - - -
-
- - - -
-
-
diff --git a/app/kubernetes/views/dashboard/dashboard.js b/app/kubernetes/views/dashboard/dashboard.js deleted file mode 100644 index c2ada72c2..000000000 --- a/app/kubernetes/views/dashboard/dashboard.js +++ /dev/null @@ -1,8 +0,0 @@ -angular.module('portainer.kubernetes').component('kubernetesDashboardView', { - templateUrl: './dashboard.html', - controller: 'KubernetesDashboardController', - controllerAs: 'ctrl', - bindings: { - endpoint: '<', - }, -}); diff --git a/app/kubernetes/views/dashboard/dashboardController.js b/app/kubernetes/views/dashboard/dashboardController.js deleted file mode 100644 index f080f949c..000000000 --- a/app/kubernetes/views/dashboard/dashboardController.js +++ /dev/null @@ -1,96 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash-es'; -import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper'; -import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper'; -import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; - -class KubernetesDashboardController { - /* @ngInject */ - constructor( - $async, - Notifications, - EndpointService, - KubernetesResourcePoolService, - KubernetesApplicationService, - KubernetesConfigurationService, - KubernetesVolumeService, - Authentication, - TagService - ) { - this.$async = $async; - this.Notifications = Notifications; - this.EndpointService = EndpointService; - this.KubernetesResourcePoolService = KubernetesResourcePoolService; - this.KubernetesApplicationService = KubernetesApplicationService; - this.KubernetesConfigurationService = KubernetesConfigurationService; - this.KubernetesVolumeService = KubernetesVolumeService; - this.Authentication = Authentication; - this.TagService = TagService; - - this.onInit = this.onInit.bind(this); - this.getAll = this.getAll.bind(this); - this.getAllAsync = this.getAllAsync.bind(this); - } - - async getAllAsync() { - const isAdmin = this.Authentication.isAdmin(); - const storageClasses = this.endpoint.Kubernetes.Configuration.StorageClasses; - this.showEnvUrl = this.endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnDockerEnvironment && this.endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment; - - try { - const [pools, applications, configurations, volumes, tags] = await Promise.all([ - this.KubernetesResourcePoolService.get(), - this.KubernetesApplicationService.get(), - this.KubernetesConfigurationService.get(), - this.KubernetesVolumeService.get(undefined, storageClasses), - this.TagService.tags(), - ]); - this.applications = applications; - this.volumes = volumes; - - this.endpointTags = this.endpoint.TagIds.length - ? _.join( - _.filter( - _.map(this.endpoint.TagIds, (id) => { - const tag = tags.find((tag) => tag.Id === id); - return tag ? tag.Name : ''; - }), - Boolean - ), - ', ' - ) - : '-'; - - if (!isAdmin) { - this.pools = _.filter(pools, (pool) => !KubernetesNamespaceHelper.isSystemNamespace(pool.Namespace.Name)); - this.configurations = _.filter(configurations, (config) => !KubernetesConfigurationHelper.isSystemToken(config)); - } else { - this.pools = pools; - this.configurations = configurations; - } - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to load dashboard data'); - } - } - - getAll() { - return this.$async(this.getAllAsync); - } - - async onInit() { - this.state = { - viewReady: false, - }; - - await this.getAll(); - - this.state.viewReady = true; - } - - $onInit() { - return this.$async(this.onInit); - } -} - -export default KubernetesDashboardController; -angular.module('portainer.kubernetes').controller('KubernetesDashboardController', KubernetesDashboardController); diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 17f6d4916..45124cf47 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -121,7 +121,17 @@ export const componentsModule = angular .component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, [])) .component( 'dashboardItem', - r2a(DashboardItem, ['icon', 'type', 'value', 'children']) + r2a(DashboardItem, [ + 'icon', + 'type', + 'value', + 'to', + 'children', + 'pluralType', + 'isLoading', + 'isRefetching', + 'dataCy', + ]) ) .component( 'datatableSearchbar', diff --git a/app/portainer/tags/queries.ts b/app/portainer/tags/queries.ts index dd730a3f3..b7ddff481 100644 --- a/app/portainer/tags/queries.ts +++ b/app/portainer/tags/queries.ts @@ -5,6 +5,7 @@ import { withError, withInvalidate, } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; import { createTag, getTags } from './tags.service'; import { Tag, TagId } from './types'; @@ -24,6 +25,14 @@ export function useTags({ }); } +export function useTagsForEnvironment(environmentId: EnvironmentId) { + const { data: tags, isLoading } = useTags({ + select: (tags) => tags.filter((tag) => tag.Endpoints[environmentId]), + }); + + return { tags, isLoading }; +} + export function useCreateTagMutation() { const queryClient = useQueryClient(); diff --git a/app/portainer/tags/types.ts b/app/portainer/tags/types.ts index f7c0de9a0..3440871e4 100644 --- a/app/portainer/tags/types.ts +++ b/app/portainer/tags/types.ts @@ -3,4 +3,5 @@ export type TagId = number; export interface Tag { ID: TagId; Name: string; + Endpoints: Record; } diff --git a/app/react/azure/DashboardView/DashboardView.tsx b/app/react/azure/DashboardView/DashboardView.tsx index 2cc1a11f7..6815e9ae3 100644 --- a/app/react/azure/DashboardView/DashboardView.tsx +++ b/app/react/azure/DashboardView/DashboardView.tsx @@ -34,12 +34,15 @@ export function DashboardView() { {!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && ( diff --git a/app/react/components/DashboardItem/DashboardGrid.css b/app/react/components/DashboardItem/DashboardGrid.css deleted file mode 100644 index a2f844a59..000000000 --- a/app/react/components/DashboardItem/DashboardGrid.css +++ /dev/null @@ -1,3 +0,0 @@ -.dashboard-grid { - @apply grid grid-cols-2 gap-3; -} diff --git a/app/react/components/DashboardItem/DashboardGrid.tsx b/app/react/components/DashboardItem/DashboardGrid.tsx index 349d5a217..bf2b95472 100644 --- a/app/react/components/DashboardItem/DashboardGrid.tsx +++ b/app/react/components/DashboardItem/DashboardGrid.tsx @@ -1,7 +1,5 @@ import { PropsWithChildren } from 'react'; -import './DashboardGrid.css'; - export function DashboardGrid({ children }: PropsWithChildren) { - return
{children}
; + return
{children}
; } diff --git a/app/react/components/DashboardItem/DashboardItem.tsx b/app/react/components/DashboardItem/DashboardItem.tsx index 5a2492bbe..bf2107b3c 100644 --- a/app/react/components/DashboardItem/DashboardItem.tsx +++ b/app/react/components/DashboardItem/DashboardItem.tsx @@ -1,25 +1,62 @@ import { ReactNode } from 'react'; import clsx from 'clsx'; +import { Loader2 } from 'lucide-react'; import { Icon, IconProps } from '@/react/components/Icon'; import { pluralize } from '@/portainer/helpers/strings'; +import { Link } from '@@/Link'; + interface Props extends IconProps { - value?: number; type: string; + pluralType?: string; // in case the pluralise function isn't suitable + isLoading?: boolean; + isRefetching?: boolean; + value?: number; + to?: string; children?: ReactNode; + dataCy?: string; } -export function DashboardItem({ value, icon, type, children }: Props) { - return ( +export function DashboardItem({ + icon, + type, + pluralType, + isLoading, + isRefetching, + value, + to, + children, + dataCy, +}: Props) { + const Item = (
+
+ Refreshing total + +
+
+ Loading total + +
- {typeof value !== 'undefined' ? value : '-'} + {typeof value === 'undefined' ? '-' : value}
- {pluralize(value || 0, type)} + {pluralize(value || 0, type, pluralType)}
@@ -61,4 +98,13 @@ export function DashboardItem({ value, icon, type, children }: Props) {
); + + if (to) { + return ( + + {Item} + + ); + } + return Item; } diff --git a/app/react/components/PageHeader/PageHeader.module.css b/app/react/components/PageHeader/PageHeader.module.css deleted file mode 100644 index bc4eb823d..000000000 --- a/app/react/components/PageHeader/PageHeader.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.reloadButton { - padding: 0; - margin: 0; -} diff --git a/app/react/components/PageHeader/PageHeader.tsx b/app/react/components/PageHeader/PageHeader.tsx index bd38f7945..c2f854ad3 100644 --- a/app/react/components/PageHeader/PageHeader.tsx +++ b/app/react/components/PageHeader/PageHeader.tsx @@ -7,7 +7,6 @@ import { Breadcrumbs } from './Breadcrumbs'; import { Crumb } from './Breadcrumbs/Breadcrumbs'; import { HeaderContainer } from './HeaderContainer'; import { HeaderTitle } from './HeaderTitle'; -import styles from './PageHeader.module.css'; interface Props { id?: string; @@ -42,7 +41,7 @@ export function PageHeader({ color="none" size="large" onClick={onClickedRefresh} - className={styles.reloadButton} + className="m-0 p-0 focus:text-inherit" disabled={loading} > diff --git a/app/react/components/PageHeader/UserMenu.tsx b/app/react/components/PageHeader/UserMenu.tsx index b6e1de2a5..0e7fe48ef 100644 --- a/app/react/components/PageHeader/UserMenu.tsx +++ b/app/react/components/PageHeader/UserMenu.tsx @@ -8,6 +8,7 @@ import { UISrefProps, useSref } from '@uirouter/react'; import clsx from 'clsx'; import { User, ChevronDown } from 'lucide-react'; +import { queryClient } from '@/react-tools/react-query'; import { AutomationTestingProps } from '@/types'; import { useUser } from '@/react/hooks/useUser'; @@ -78,7 +79,10 @@ function MenuLink({ return ( { + queryClient.clear(); + anchorProps.onClick(e); + }} className={styles.menuLink} aria-label={label} data-cy={dataCy} diff --git a/app/react/components/TagSelector/TagSelector.test.tsx b/app/react/components/TagSelector/TagSelector.test.tsx index a547ae973..c5eb3d157 100644 --- a/app/react/components/TagSelector/TagSelector.test.tsx +++ b/app/react/components/TagSelector/TagSelector.test.tsx @@ -19,10 +19,12 @@ test('should show the selected tags', async () => { { ID: 1, Name: 'tag1', + Endpoints: {}, }, { ID: 2, Name: 'tag2', + Endpoints: {}, }, ]; diff --git a/app/react/kubernetes/DashboardView/.keep b/app/react/kubernetes/DashboardView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/DashboardView/DashboardView.tsx b/app/react/kubernetes/DashboardView/DashboardView.tsx new file mode 100644 index 000000000..81820efb5 --- /dev/null +++ b/app/react/kubernetes/DashboardView/DashboardView.tsx @@ -0,0 +1,93 @@ +import { Box, Database, Layers, Lock } from 'lucide-react'; +import { useQueryClient } from 'react-query'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { DashboardGrid } from '@@/DashboardItem/DashboardGrid'; +import { DashboardItem } from '@@/DashboardItem/DashboardItem'; +import { PageHeader } from '@@/PageHeader'; + +import { useNamespaces } from '../namespaces/queries'; +import { useApplicationsForCluster } from '../applications/queries'; +import { useConfigurationsForCluster } from '../configs/queries'; +import { usePVCsForCluster } from '../volumes/queries'; + +import { EnvironmentInfo } from './EnvironmentInfo'; + +export function DashboardView() { + const queryClient = useQueryClient(); + const environmentId = useEnvironmentId(); + const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId); + const namespaceNames = namespaces && Object.keys(namespaces); + const { data: applications, ...applicationsQuery } = + useApplicationsForCluster(environmentId, namespaceNames); + const { data: configurations, ...configurationsQuery } = + useConfigurationsForCluster(environmentId, namespaceNames); + const { data: pvcs, ...pvcsQuery } = usePVCsForCluster( + environmentId, + namespaceNames + ); + + return ( + <> + + queryClient.invalidateQueries(['environments', environmentId]) + } + /> +
+ + + + + + + +
+ + ); +} diff --git a/app/react/kubernetes/DashboardView/EnvironmentInfo.tsx b/app/react/kubernetes/DashboardView/EnvironmentInfo.tsx new file mode 100644 index 000000000..a3c45d680 --- /dev/null +++ b/app/react/kubernetes/DashboardView/EnvironmentInfo.tsx @@ -0,0 +1,50 @@ +import { Gauge } from 'lucide-react'; + +import { stripProtocol } from '@/portainer/filters/filters'; +import { useTagsForEnvironment } from '@/portainer/tags/queries'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useEnvironment } from '@/react/portainer/environments/queries'; + +import { Widget, WidgetTitle, WidgetBody } from '@@/Widget'; + +export function EnvironmentInfo() { + const environmentId = useEnvironmentId(); + const { data: environmentData, ...environmentQuery } = + useEnvironment(environmentId); + const tagsQuery = useTagsForEnvironment(environmentId); + const tagNames = tagsQuery.tags?.map((tag) => tag.Name).join(', ') || '-'; + + return ( + + + + {environmentQuery.isError &&
Failed to load environment
} + {environmentData && ( + + + + + + + + + + + + + + + +
Environment + {environmentData.Name} +
URL + {stripProtocol(environmentData.URL) || '-'} +
Tags{tagNames}
+ )} +
+
+ ); +} diff --git a/app/react/kubernetes/DashboardView/index.ts b/app/react/kubernetes/DashboardView/index.ts new file mode 100644 index 000000000..ea829dbf3 --- /dev/null +++ b/app/react/kubernetes/DashboardView/index.ts @@ -0,0 +1 @@ +export { DashboardView } from './DashboardView'; diff --git a/app/react/kubernetes/applications/.keep b/app/react/kubernetes/applications/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/applications/queries.ts b/app/react/kubernetes/applications/queries.ts new file mode 100644 index 000000000..8f6aab7f2 --- /dev/null +++ b/app/react/kubernetes/applications/queries.ts @@ -0,0 +1,21 @@ +import { useQuery } from 'react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withError } from '@/react-tools/react-query'; + +import { getApplicationsListForCluster } from './service'; + +// useQuery to get a list of all applications from an array of namespaces +export function useApplicationsForCluster( + environemtId: EnvironmentId, + namespaces?: string[] +) { + return useQuery( + ['environments', environemtId, 'kubernetes', 'applications'], + () => namespaces && getApplicationsListForCluster(environemtId, namespaces), + { + ...withError('Unable to retrieve applications'), + enabled: !!namespaces, + } + ); +} diff --git a/app/react/kubernetes/applications/service.ts b/app/react/kubernetes/applications/service.ts new file mode 100644 index 000000000..c95418396 --- /dev/null +++ b/app/react/kubernetes/applications/service.ts @@ -0,0 +1,141 @@ +import { Pod, PodList } from 'kubernetes-types/core/v1'; +import { + Deployment, + DeploymentList, + DaemonSet, + DaemonSetList, + StatefulSet, + StatefulSetList, +} from 'kubernetes-types/apps/v1'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export async function getApplicationsListForCluster( + environmentId: EnvironmentId, + namespaces: string[] +) { + try { + const applications = await Promise.all( + namespaces.map((namespace) => + getApplicationsListForNamespace(environmentId, namespace) + ) + ); + return applications.flat(); + } catch (e) { + throw parseAxiosError( + e as Error, + 'Unable to retrieve applications for cluster' + ); + } +} + +// get a list of all Deployments, DaemonSets and StatefulSets in one namespace +export async function getApplicationsListForNamespace( + environmentId: EnvironmentId, + namespace: string +) { + try { + const [deployments, daemonSets, statefulSets, pods] = await Promise.all([ + getDeployments(environmentId, namespace), + getDaemonSets(environmentId, namespace), + getStatefulSets(environmentId, namespace), + getPods(environmentId, namespace), + ]); + // find all pods which are 'naked' (not owned by a deployment, daemonset or statefulset) + const nakedPods = getNakedPods(pods, deployments, daemonSets, statefulSets); + return [...deployments, ...daemonSets, ...statefulSets, ...nakedPods]; + } catch (e) { + throw parseAxiosError( + e as Error, + `Unable to retrieve applications in namespace ${namespace}` + ); + } +} + +async function getDeployments(environmentId: EnvironmentId, namespace: string) { + try { + const { data } = await axios.get( + buildUrl(environmentId, namespace, 'deployments') + ); + return data.items; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve deployments'); + } +} + +async function getDaemonSets(environmentId: EnvironmentId, namespace: string) { + try { + const { data } = await axios.get( + buildUrl(environmentId, namespace, 'daemonsets') + ); + return data.items; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve daemonsets'); + } +} + +async function getStatefulSets( + environmentId: EnvironmentId, + namespace: string +) { + try { + const { data } = await axios.get( + buildUrl(environmentId, namespace, 'statefulsets') + ); + return data.items; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve statefulsets'); + } +} + +async function getPods(environmentId: EnvironmentId, namespace: string) { + try { + const { data } = await axios.get( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods` + ); + return data.items; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve pods'); + } +} + +function buildUrl( + environmentId: EnvironmentId, + namespace: string, + appResource: 'deployments' | 'daemonsets' | 'statefulsets' +) { + return `/endpoints/${environmentId}/kubernetes/apis/apps/v1/namespaces/${namespace}/${appResource}`; +} + +function getNakedPods( + pods: Pod[], + deployments: Deployment[], + daemonSets: DaemonSet[], + statefulSets: StatefulSet[] +) { + // naked pods are pods which are not owned by a deployment, daemonset, statefulset or replicaset + // https://kubernetes.io/docs/concepts/configuration/overview/#naked-pods-vs-replicasets-deployments-and-jobs + const appLabels = [ + ...deployments.map((deployment) => deployment.spec?.selector.matchLabels), + ...daemonSets.map((daemonSet) => daemonSet.spec?.selector.matchLabels), + ...statefulSets.map( + (statefulSet) => statefulSet.spec?.selector.matchLabels + ), + ]; + + const nakedPods = pods.filter((pod) => { + const podLabels = pod.metadata?.labels; + // if the pod has no labels, it is naked + if (!podLabels) return true; + // if the pod has labels, but no app labels, it is naked + return !appLabels.some((appLabel) => { + if (!appLabel) return false; + return Object.entries(appLabel).every( + ([key, value]) => podLabels[key] === value + ); + }); + }); + + return nakedPods; +} diff --git a/app/react/kubernetes/configs/queries.ts b/app/react/kubernetes/configs/queries.ts index ad6cb4ef2..2b27653ff 100644 --- a/app/react/kubernetes/configs/queries.ts +++ b/app/react/kubernetes/configs/queries.ts @@ -2,9 +2,11 @@ import { useQuery } from 'react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { error as notifyError } from '@/portainer/services/notifications'; +import { withError } from '@/react-tools/react-query'; -import { getConfigMaps } from './service'; +import { getConfigurations, getConfigMapsForCluster } from './service'; +// returns a usequery hook for the formatted list of configmaps and secrets export function useConfigurations( environmentId: EnvironmentId, namespace?: string @@ -18,7 +20,7 @@ export function useConfigurations( namespace, 'configurations', ], - () => (namespace ? getConfigMaps(environmentId, namespace) : []), + () => (namespace ? getConfigurations(environmentId, namespace) : []), { onError: (err) => { notifyError('Failure', err as Error, 'Unable to get configurations'); @@ -27,3 +29,17 @@ export function useConfigurations( } ); } + +export function useConfigurationsForCluster( + environemtId: EnvironmentId, + namespaces?: string[] +) { + return useQuery( + ['environments', environemtId, 'kubernetes', 'configmaps'], + () => namespaces && getConfigMapsForCluster(environemtId, namespaces), + { + ...withError('Unable to retrieve applications'), + enabled: !!namespaces, + } + ); +} diff --git a/app/react/kubernetes/configs/service.ts b/app/react/kubernetes/configs/service.ts index cd3a4c6b6..6e48ec6db 100644 --- a/app/react/kubernetes/configs/service.ts +++ b/app/react/kubernetes/configs/service.ts @@ -3,7 +3,8 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { Configuration } from './types'; -export async function getConfigMaps( +// returns the formatted list of configmaps and secrets +export async function getConfigurations( environmentId: EnvironmentId, namespace: string ) { @@ -16,3 +17,20 @@ export async function getConfigMaps( throw parseAxiosError(e as Error, 'Unable to retrieve configmaps'); } } + +export async function getConfigMapsForCluster( + environmentId: EnvironmentId, + namespaces: string[] +) { + try { + const configmaps = await Promise.all( + namespaces.map((namespace) => getConfigurations(environmentId, namespace)) + ); + return configmaps.flat(); + } catch (e) { + throw parseAxiosError( + e as Error, + 'Unable to retrieve ConfigMaps for cluster' + ); + } +} diff --git a/app/react/kubernetes/namespaces/.keep b/app/react/kubernetes/namespaces/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/namespaces/queries.ts b/app/react/kubernetes/namespaces/queries.ts index 057134698..397c878f5 100644 --- a/app/react/kubernetes/namespaces/queries.ts +++ b/app/react/kubernetes/namespaces/queries.ts @@ -3,9 +3,11 @@ import { useQuery } from 'react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { error as notifyError } from '@/portainer/services/notifications'; -import { getIngresses } from '../ingresses/service'; - -import { getNamespaces, getNamespace } from './service'; +import { + getNamespaces, + getNamespace, + getSelfSubjectAccessReview, +} from './service'; import { Namespaces } from './types'; export function useNamespaces(environmentId: EnvironmentId) { @@ -13,18 +15,23 @@ export function useNamespaces(environmentId: EnvironmentId) { ['environments', environmentId, 'kubernetes', 'namespaces'], async () => { const namespaces = await getNamespaces(environmentId); - const settledNamespacesPromise = await Promise.allSettled( - Object.keys(namespaces).map((namespace) => - getIngresses(environmentId, namespace).then(() => namespace) + const namespaceNames = Object.keys(namespaces); + // use seflsubjectaccess reviews to avoid forbidden requests + const allNamespaceAccessReviews = await Promise.all( + namespaceNames.map((namespaceName) => + getSelfSubjectAccessReview(environmentId, namespaceName) ) ); - const ns: Namespaces = {}; - settledNamespacesPromise.forEach((namespace) => { - if (namespace.status === 'fulfilled') { - ns[namespace.value] = namespaces[namespace.value]; + const allowedNamespacesNames = allNamespaceAccessReviews + .filter((accessReview) => accessReview.status.allowed) + .map((accessReview) => accessReview.spec.resourceAttributes.namespace); + const allowedNamespaces = namespaceNames.reduce((acc, namespaceName) => { + if (allowedNamespacesNames.includes(namespaceName)) { + acc[namespaceName] = namespaces[namespaceName]; } - }); - return ns; + return acc; + }, {} as Namespaces); + return allowedNamespaces; }, { onError: (err) => { diff --git a/app/react/kubernetes/namespaces/service.ts b/app/react/kubernetes/namespaces/service.ts index 57e91689d..27549c2f3 100644 --- a/app/react/kubernetes/namespaces/service.ts +++ b/app/react/kubernetes/namespaces/service.ts @@ -1,7 +1,7 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { Namespaces } from './types'; +import { Namespaces, SelfSubjectAccessReviewResponse } from './types'; export async function getNamespace( environmentId: EnvironmentId, @@ -28,6 +28,39 @@ export async function getNamespaces(environmentId: EnvironmentId) { } } +export async function getSelfSubjectAccessReview( + environmentId: EnvironmentId, + namespaceName: string, + verb = 'list', + resource = 'deployments', + group = 'apps' +) { + try { + const { data: accessReview } = + await axios.post( + `endpoints/${environmentId}/kubernetes/apis/authorization.k8s.io/v1/selfsubjectaccessreviews`, + { + spec: { + resourceAttributes: { + group, + resource, + verb, + namespace: namespaceName, + }, + }, + apiVersion: 'authorization.k8s.io/v1', + kind: 'SelfSubjectAccessReview', + } + ); + return accessReview; + } catch (e) { + throw parseAxiosError( + e as Error, + 'Unable to retrieve self subject access review' + ); + } +} + function buildUrl(environmentId: EnvironmentId, namespace?: string) { let url = `kubernetes/${environmentId}/namespaces`; diff --git a/app/react/kubernetes/namespaces/types.ts b/app/react/kubernetes/namespaces/types.ts index 20436c306..86aae0c48 100644 --- a/app/react/kubernetes/namespaces/types.ts +++ b/app/react/kubernetes/namespaces/types.ts @@ -4,3 +4,14 @@ export interface Namespaces { IsSystem: boolean; }; } + +export interface SelfSubjectAccessReviewResponse { + status: { + allowed: boolean; + }; + spec: { + resourceAttributes: { + namespace: string; + }; + }; +} diff --git a/app/react/kubernetes/volumes/queries.ts b/app/react/kubernetes/volumes/queries.ts new file mode 100644 index 000000000..c03856416 --- /dev/null +++ b/app/react/kubernetes/volumes/queries.ts @@ -0,0 +1,21 @@ +import { useQuery } from 'react-query'; + +import { withError } from '@/react-tools/react-query'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { getPVCsForCluster } from './service'; + +// useQuery to get a list of all applications from an array of namespaces +export function usePVCsForCluster( + environemtId: EnvironmentId, + namespaces?: string[] +) { + return useQuery( + ['environments', environemtId, 'kubernetes', 'pvcs'], + () => namespaces && getPVCsForCluster(environemtId, namespaces), + { + ...withError('Unable to retrieve applications'), + enabled: !!namespaces, + } + ); +} diff --git a/app/react/kubernetes/volumes/service.ts b/app/react/kubernetes/volumes/service.ts new file mode 100644 index 000000000..3f79e7014 --- /dev/null +++ b/app/react/kubernetes/volumes/service.ts @@ -0,0 +1,33 @@ +import { PersistentVolumeClaimList } from 'kubernetes-types/core/v1'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export async function getPVCsForCluster( + environmentId: EnvironmentId, + namespaces: string[] +) { + try { + const pvcs = await Promise.all( + namespaces.map((namespace) => getPVCs(environmentId, namespace)) + ); + return pvcs.flat(); + } catch (e) { + throw parseAxiosError( + e as Error, + 'Unable to retrieve persistent volume claims for cluster' + ); + } +} + +// get all persistent volume claims for a namespace +export async function getPVCs(environmentId: EnvironmentId, namespace: string) { + try { + const { data } = await axios.get( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/persistentvolumeclaims` + ); + return data.items; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve deployments'); + } +} diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts index 639bad9f1..994434545 100644 --- a/app/react/portainer/environments/types.ts +++ b/app/react/portainer/environments/types.ts @@ -50,8 +50,16 @@ export type IngressClass = { Type: string; }; +interface StorageClass { + Name: string; + AccessModes: string[]; + AllowVolumeExpansion: boolean; + Provisioner: string; +} + export interface KubernetesConfiguration { UseLoadBalancer?: boolean; + StorageClasses?: StorageClass[]; UseServerMetrics?: boolean; EnableResourceOverCommit?: boolean; ResourceOverCommitPercentage?: number; diff --git a/app/setup-tests/server-handlers.ts b/app/setup-tests/server-handlers.ts index 94e1590e9..f520191f8 100644 --- a/app/setup-tests/server-handlers.ts +++ b/app/setup-tests/server-handlers.ts @@ -17,8 +17,8 @@ import { dockerHandlers } from './setup-handlers/docker'; import { userHandlers } from './setup-handlers/users'; const tags: Tag[] = [ - { ID: 1, Name: 'tag1' }, - { ID: 2, Name: 'tag2' }, + { ID: 1, Name: 'tag1', Endpoints: {} }, + { ID: 2, Name: 'tag2', Endpoints: {} }, ]; const licenseInfo: LicenseInfo = { @@ -68,7 +68,7 @@ export const handlers = [ rest.get('/api/tags', (req, res, ctx) => res(ctx.json(tags))), rest.post<{ name: string }>('/api/tags', (req, res, ctx) => { const tagName = req.body.name; - const tag = { ID: tags.length + 1, Name: tagName }; + const tag = { ID: tags.length + 1, Name: tagName, Endpoints: {} }; tags.push(tag); return res(ctx.json(tag)); }), diff --git a/package.json b/package.json index 38c0187bc..d38131e50 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,7 @@ "html-webpack-plugin": "^5.5.0", "husky": "4.2.5", "jest": "^27.4.3", + "kubernetes-types": "^1.26.0", "lint-staged": ">=10", "load-grunt-tasks": "^3.5.2", "lodash-webpack-plugin": "^0.11.6", diff --git a/yarn.lock b/yarn.lock index 515fb3e5e..9ac79504b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12944,6 +12944,11 @@ klona@^2.0.5: resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.5.tgz#d166574d90076395d9963aa7a928fabb8d76afbc" integrity sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ== +kubernetes-types@^1.26.0: + version "1.26.0" + resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.26.0.tgz#47b7db20eb084931cfebf67937cc6b9091dc3da3" + integrity sha512-jv0XaTIGW/p18jaiKRD85hLTYWx0yEj+cb6PDX3GdNa3dWoRxnD4Gv7+bE6C/ehcsp2skcdy34vT25jbPofDIQ== + kuler@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" From 2bfc956f583348a0d8e6d1a8b9c270cbd5a417b8 Mon Sep 17 00:00:00 2001 From: Matt Hook Date: Wed, 8 Mar 2023 13:24:59 +1300 Subject: [PATCH 22/55] bump version to 2.19 (#8617) --- api/http/handler/handler.go | 2 +- api/portainer.go | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index e79b7b9e9..50a7dc040 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -82,7 +82,7 @@ type Handler struct { } // @title PortainerCE API -// @version 2.18.0 +// @version 2.19.0 // @description.markdown api-description.md // @termsOfService diff --git a/api/portainer.go b/api/portainer.go index 0a5ae6380..e6ed05361 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1510,7 +1510,7 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "2.18.0" + APIVersion = "2.19.0" // Edition is what this edition of Portainer is called Edition = PortainerCE // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax diff --git a/package.json b/package.json index d38131e50..0f13aa23d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "2.18.0", + "version": "2.19.0", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git" From 0669ad77d37f946793fd14e8ede19652a4bef3e5 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Wed, 8 Mar 2023 02:27:34 +0200 Subject: [PATCH 23/55] fix(home): disable live connect for async [EE-5000] (#8462) --- app/react/components/buttons/Button.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/react/components/buttons/Button.tsx b/app/react/components/buttons/Button.tsx index f45d8b0e3..acd9d4f61 100644 --- a/app/react/components/buttons/Button.tsx +++ b/app/react/components/buttons/Button.tsx @@ -63,7 +63,11 @@ export function Button({ type={type} disabled={disabled} className={clsx(`btn btn-${color}`, sizeClass(size), className)} - onClick={onClick} + onClick={(e) => { + if (!disabled) { + onClick?.(e); + } + }} title={title} // eslint-disable-next-line react/jsx-props-no-spreading {...ariaProps} From 4c86be725d5035c281ecd345e4caa981f6b97d3f Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Wed, 8 Mar 2023 04:34:55 +0200 Subject: [PATCH 24/55] feat(system): upgrade portainer on kubernetes [EE-4625] (#8448) --- api/cmd/portainer/main.go | 2 +- api/http/handler/system/system_upgrade.go | 42 +++- api/internal/upgrade/upgrade.go | 143 ++----------- api/internal/upgrade/upgrade_docker.go | 121 +++++++++++ api/internal/upgrade/upgrade_kubernetes.go | 201 ++++++++++++++++++ api/platform/platform.go | 2 + .../UpgradeBEBanner/UpgradeBEBanner.tsx | 1 + build/build_binary.sh | 21 +- 8 files changed, 394 insertions(+), 139 deletions(-) create mode 100644 api/internal/upgrade/upgrade_docker.go create mode 100644 api/internal/upgrade/upgrade_kubernetes.go diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index a2a0d545b..c3479cac4 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -684,7 +684,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { log.Fatal().Msg("failed to fetch SSL settings from DB") } - upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer) + upgradeService, err := upgrade.NewService(*flags.Assets, composeDeployer, kubernetesClientFactory) if err != nil { log.Fatal().Err(err).Msg("failed initializing upgrade service") } diff --git a/api/http/handler/system/system_upgrade.go b/api/http/handler/system/system_upgrade.go index 66bb27852..0669a742d 100644 --- a/api/http/handler/system/system_upgrade.go +++ b/api/http/handler/system/system_upgrade.go @@ -8,6 +8,8 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/platform" ) type systemUpgradePayload struct { @@ -28,13 +30,19 @@ func (payload *systemUpgradePayload) Validate(r *http.Request) error { return nil } +var platformToEndpointType = map[platform.ContainerPlatform]portainer.EndpointType{ + platform.PlatformDockerStandalone: portainer.DockerEnvironment, + platform.PlatformDockerSwarm: portainer.DockerEnvironment, + platform.PlatformKubernetes: portainer.KubernetesLocalEnvironment, +} + // @id systemUpgrade // @summary Upgrade Portainer to BE // @description Upgrade Portainer to BE // @description **Access policy**: administrator // @tags system // @produce json -// @success 200 {object} status "Success" +// @success 204 {object} status "Success" // @router /system/upgrade [post] func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { payload, err := request.GetPayload[systemUpgradePayload](r) @@ -42,10 +50,40 @@ func (handler *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) *h return httperror.BadRequest("Invalid request payload", err) } - err = handler.upgradeService.Upgrade(payload.License) + environment, err := handler.guessLocalEndpoint() + if err != nil { + return httperror.InternalServerError("Failed to guess local endpoint", err) + } + + err = handler.upgradeService.Upgrade(environment, payload.License) if err != nil { return httperror.InternalServerError("Failed to upgrade Portainer", err) } return response.Empty(w) } + +func (handler *Handler) guessLocalEndpoint() (*portainer.Endpoint, error) { + platform, err := platform.DetermineContainerPlatform() + if err != nil { + return nil, errors.Wrap(err, "failed to determine container platform") + } + + endpointType, ok := platformToEndpointType[platform] + if !ok { + return nil, errors.New("failed to determine endpoint type") + } + + endpoints, err := handler.dataStore.Endpoint().Endpoints() + if err != nil { + return nil, errors.Wrap(err, "failed to retrieve endpoints") + } + + for _, endpoint := range endpoints { + if endpoint.Type == endpointType { + return &endpoint, nil + } + } + + return nil, errors.New("failed to find local endpoint") +} diff --git a/api/internal/upgrade/upgrade.go b/api/internal/upgrade/upgrade.go index 73283972d..a4552fae3 100644 --- a/api/internal/upgrade/upgrade.go +++ b/api/internal/upgrade/upgrade.go @@ -1,23 +1,13 @@ package upgrade import ( - "bytes" - "context" "fmt" - "os" - "strings" - "time" - "github.com/cbroglie/mustache" - "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/filters" "github.com/pkg/errors" libstack "github.com/portainer/docker-compose-wrapper" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/docker" - "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/platform" - "github.com/rs/zerolog/log" ) const ( @@ -36,19 +26,23 @@ const ( ) type Service interface { - Upgrade(licenseKey string) error + Upgrade(environment *portainer.Endpoint, licenseKey string) error } type service struct { - composeDeployer libstack.Deployer - isUpdating bool - platform platform.ContainerPlatform - assetsPath string + composeDeployer libstack.Deployer + kubernetesClientFactory *cli.ClientFactory + + isUpdating bool + platform platform.ContainerPlatform + + assetsPath string } func NewService( assetsPath string, composeDeployer libstack.Deployer, + kubernetesClientFactory *cli.ClientFactory, ) (Service, error) { platform, err := platform.DetermineContainerPlatform() if err != nil { @@ -56,13 +50,14 @@ func NewService( } return &service{ - assetsPath: assetsPath, - composeDeployer: composeDeployer, - platform: platform, + assetsPath: assetsPath, + composeDeployer: composeDeployer, + kubernetesClientFactory: kubernetesClientFactory, + platform: platform, }, nil } -func (service *service) Upgrade(licenseKey string) error { +func (service *service) Upgrade(environment *portainer.Endpoint, licenseKey string) error { service.isUpdating = true switch service.platform { @@ -70,113 +65,9 @@ func (service *service) Upgrade(licenseKey string) error { return service.upgradeDocker(licenseKey, portainer.APIVersion, "standalone") case platform.PlatformDockerSwarm: return service.upgradeDocker(licenseKey, portainer.APIVersion, "swarm") - // case platform.PlatformKubernetes: - // case platform.PlatformPodman: - // case platform.PlatformNomad: - // default: + case platform.PlatformKubernetes: + return service.upgradeKubernetes(environment, licenseKey, portainer.APIVersion) } return fmt.Errorf("unsupported platform %s", service.platform) } - -func (service *service) upgradeDocker(licenseKey, version, envType string) error { - ctx := context.TODO() - templateName := filesystem.JoinPaths(service.assetsPath, "mustache-templates", mustacheUpgradeDockerTemplateFile) - - portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar) - if portainerImagePrefix == "" { - portainerImagePrefix = "portainer/portainer-ee" - } - - image := fmt.Sprintf("%s:%s", portainerImagePrefix, version) - - skipPullImage := os.Getenv(skipPullImageEnvVar) - - if err := service.checkImage(ctx, image, skipPullImage != ""); err != nil { - return err - } - - composeFile, err := mustache.RenderFile(templateName, map[string]string{ - "image": image, - "skip_pull_image": skipPullImage, - "updater_image": os.Getenv(updaterImageEnvVar), - "license": licenseKey, - "envType": envType, - }) - - log.Debug(). - Str("composeFile", composeFile). - Msg("Compose file for upgrade") - - if err != nil { - return errors.Wrap(err, "failed to render upgrade template") - } - - tmpDir := os.TempDir() - timeId := time.Now().Unix() - filePath := filesystem.JoinPaths(tmpDir, fmt.Sprintf("upgrade-%d.yml", timeId)) - - r := bytes.NewReader([]byte(composeFile)) - - err = filesystem.CreateFile(filePath, r) - if err != nil { - return errors.Wrap(err, "failed to create upgrade compose file") - } - - projectName := fmt.Sprintf( - "portainer-upgrade-%d-%s", - timeId, - strings.Replace(version, ".", "-", -1)) - - err = service.composeDeployer.Deploy( - ctx, - []string{filePath}, - libstack.DeployOptions{ - ForceRecreate: true, - AbortOnContainerExit: true, - Options: libstack.Options{ - ProjectName: projectName, - }, - }, - ) - - // optimally, server was restarted by the updater, so we should not reach this point - - if err != nil { - return errors.Wrap(err, "failed to deploy upgrade stack") - } - - return errors.New("upgrade failed: server should have been restarted by the updater") -} - -func (service *service) checkImage(ctx context.Context, image string, skipPullImage bool) error { - cli, err := docker.CreateClientFromEnv() - if err != nil { - return errors.Wrap(err, "failed to create docker client") - } - - if skipPullImage { - filters := filters.NewArgs() - filters.Add("reference", image) - images, err := cli.ImageList(ctx, types.ImageListOptions{ - Filters: filters, - }) - if err != nil { - return errors.Wrap(err, "failed to list images") - } - - if len(images) == 0 { - return errors.Errorf("image %s not found locally", image) - } - - return nil - } else { - // check if available on registry - _, err := cli.DistributionInspect(ctx, image, "") - if err != nil { - return errors.Errorf("image %s not found on registry", image) - } - - return nil - } -} diff --git a/api/internal/upgrade/upgrade_docker.go b/api/internal/upgrade/upgrade_docker.go new file mode 100644 index 000000000..8d569fcef --- /dev/null +++ b/api/internal/upgrade/upgrade_docker.go @@ -0,0 +1,121 @@ +package upgrade + +import ( + "bytes" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/cbroglie/mustache" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/client" + "github.com/pkg/errors" + libstack "github.com/portainer/docker-compose-wrapper" + "github.com/portainer/portainer/api/filesystem" + "github.com/rs/zerolog/log" +) + +func (service *service) upgradeDocker(licenseKey, version, envType string) error { + ctx := context.TODO() + templateName := filesystem.JoinPaths(service.assetsPath, "mustache-templates", mustacheUpgradeDockerTemplateFile) + + portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar) + if portainerImagePrefix == "" { + portainerImagePrefix = "portainer/portainer-ee" + } + + image := fmt.Sprintf("%s:%s", portainerImagePrefix, version) + + skipPullImage := os.Getenv(skipPullImageEnvVar) + + if err := service.checkImageForDocker(ctx, image, skipPullImage != ""); err != nil { + return err + } + + composeFile, err := mustache.RenderFile(templateName, map[string]string{ + "image": image, + "skip_pull_image": skipPullImage, + "updater_image": os.Getenv(updaterImageEnvVar), + "license": licenseKey, + "envType": envType, + }) + + log.Debug(). + Str("composeFile", composeFile). + Msg("Compose file for upgrade") + + if err != nil { + return errors.Wrap(err, "failed to render upgrade template") + } + + tmpDir := os.TempDir() + timeId := time.Now().Unix() + filePath := filesystem.JoinPaths(tmpDir, fmt.Sprintf("upgrade-%d.yml", timeId)) + + r := bytes.NewReader([]byte(composeFile)) + + err = filesystem.CreateFile(filePath, r) + if err != nil { + return errors.Wrap(err, "failed to create upgrade compose file") + } + + projectName := fmt.Sprintf( + "portainer-upgrade-%d-%s", + timeId, + strings.Replace(version, ".", "-", -1)) + + err = service.composeDeployer.Deploy( + ctx, + []string{filePath}, + libstack.DeployOptions{ + ForceRecreate: true, + AbortOnContainerExit: true, + Options: libstack.Options{ + ProjectName: projectName, + }, + }, + ) + + // optimally, server was restarted by the updater, so we should not reach this point + + if err != nil { + return errors.Wrap(err, "failed to deploy upgrade stack") + } + + return errors.New("upgrade failed: server should have been restarted by the updater") +} + +func (service *service) checkImageForDocker(ctx context.Context, image string, skipPullImage bool) error { + cli, err := client.NewClientWithOpts(client.FromEnv) + if err != nil { + return errors.Wrap(err, "failed to create docker client") + } + + if skipPullImage { + filters := filters.NewArgs() + filters.Add("reference", image) + images, err := cli.ImageList(ctx, types.ImageListOptions{ + Filters: filters, + }) + if err != nil { + return errors.Wrap(err, "failed to list images") + } + + if len(images) == 0 { + return errors.Errorf("image %s not found locally", image) + } + + return nil + } else { + // check if available on registry + _, err := cli.DistributionInspect(ctx, image, "") + if err != nil { + return errors.Errorf("image %s not found on registry", image) + } + + return nil + } +} diff --git a/api/internal/upgrade/upgrade_kubernetes.go b/api/internal/upgrade/upgrade_kubernetes.go new file mode 100644 index 000000000..83bca9b78 --- /dev/null +++ b/api/internal/upgrade/upgrade_kubernetes.go @@ -0,0 +1,201 @@ +package upgrade + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/rs/zerolog/log" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +func ptr[T any](i T) *T { return &i } + +func (service *service) upgradeKubernetes(environment *portainer.Endpoint, licenseKey, version string) error { + ctx := context.TODO() + + kubeCLI, err := service.kubernetesClientFactory.CreateClient(environment) + if err != nil { + return errors.WithMessage(err, "failed to get kubernetes client") + } + + namespace := "portainer" + taskName := fmt.Sprintf("portainer-upgrade-%d", time.Now().Unix()) + + jobsCli := kubeCLI.BatchV1().Jobs(namespace) + + updaterImage := os.Getenv(updaterImageEnvVar) + if updaterImage == "" { + updaterImage = "portainer/portainer-updater:latest" + } + + portainerImagePrefix := os.Getenv(portainerImagePrefixEnvVar) + if portainerImagePrefix == "" { + portainerImagePrefix = "portainer/portainer-ee" + } + + image := fmt.Sprintf("%s:%s", portainerImagePrefix, version) + + if err := service.checkImageForKubernetes(ctx, kubeCLI, namespace, image); err != nil { + return err + } + + job, err := jobsCli.Create(ctx, &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: taskName, + Namespace: namespace, + }, + + Spec: batchv1.JobSpec{ + TTLSecondsAfterFinished: ptr[int32](5 * 60), // cleanup after 5 minutes + BackoffLimit: ptr[int32](0), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + + RestartPolicy: "Never", + ServiceAccountName: "portainer-sa-clusteradmin", + Containers: []corev1.Container{ + { + Name: taskName, + Image: updaterImage, + Args: []string{ + "--pretty-log", + "--log-level", "DEBUG", + "portainer", + "--env-type", "kubernetes", + "--image", image, + "--license", licenseKey, + }, + }, + }, + }, + }, + }, + }, metav1.CreateOptions{}) + + if err != nil { + return errors.WithMessage(err, "failed to create upgrade job") + } + + watcher, err := jobsCli.Watch(ctx, metav1.ListOptions{ + FieldSelector: "metadata.name=" + taskName, + TimeoutSeconds: ptr[int64](60), + }) + if err != nil { + return errors.WithMessage(err, "failed to watch upgrade job") + } + + for event := range watcher.ResultChan() { + job, ok := event.Object.(*batchv1.Job) + if !ok { + continue + } + + for _, c := range job.Status.Conditions { + if c.Type == batchv1.JobComplete { + log.Debug(). + Str("job", job.Name). + Msg("Upgrade job completed") + return nil + } + + if c.Type == batchv1.JobFailed { + return fmt.Errorf("upgrade failed: %s", c.Message) + } + } + } + + log.Debug(). + Str("job", job.Name). + Msg("Upgrade job created") + + return errors.New("upgrade failed: server should have been restarted by the updater") + +} + +func (service *service) checkImageForKubernetes(ctx context.Context, kubeCLI *kubernetes.Clientset, namespace, image string) error { + podsCli := kubeCLI.CoreV1().Pods(namespace) + + log.Debug(). + Str("image", image). + Msg("Checking image") + + podName := fmt.Sprintf("portainer-image-check-%d", time.Now().Unix()) + _, err := podsCli.Create(ctx, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + }, + Spec: corev1.PodSpec{ + RestartPolicy: "Never", + + Containers: []corev1.Container{ + { + Name: fmt.Sprint(podName, "-container"), + Image: image, + }, + }, + }, + }, metav1.CreateOptions{}) + + if err != nil { + log.Warn().Err(err).Msg("failed to create image check pod") + return errors.WithMessage(err, "failed to create image check pod") + } + + defer func() { + log.Debug(). + Str("pod", podName). + Msg("Deleting image check pod") + + if err := podsCli.Delete(ctx, podName, metav1.DeleteOptions{}); err != nil { + log.Warn().Err(err).Msg("failed to delete image check pod") + } + }() + + i := 0 + for { + time.Sleep(2 * time.Second) + + log.Debug(). + Str("image", image). + Int("try", i). + Msg("Checking image") + + i++ + + pod, err := podsCli.Get(ctx, podName, metav1.GetOptions{}) + if err != nil { + return errors.WithMessage(err, "failed to get image check pod") + } + + for _, containerStatus := range pod.Status.ContainerStatuses { + if containerStatus.Ready { + log.Debug(). + Str("image", image). + Str("pod", podName). + Msg("Image check container ready, assuming image is available") + + return nil + } + + if containerStatus.State.Waiting != nil { + if containerStatus.State.Waiting.Reason == "ErrImagePull" || containerStatus.State.Waiting.Reason == "ImagePullBackOff" { + log.Debug(). + Str("image", image). + Str("pod", podName). + Str("reason", containerStatus.State.Waiting.Reason). + Str("message", containerStatus.State.Waiting.Message). + Str("container", containerStatus.Name). + Msg("Image check container failed because of missing image") + return fmt.Errorf("image %s not found", image) + } + } + } + } +} diff --git a/api/platform/platform.go b/api/platform/platform.go index 8adee3cb3..706a844ef 100644 --- a/api/platform/platform.go +++ b/api/platform/platform.go @@ -41,10 +41,12 @@ func DetermineContainerPlatform() (ContainerPlatform, error) { if podmanModeEnvVar == "1" { return PlatformPodman, nil } + serviceHostKubernetesEnvVar := os.Getenv(KubernetesServiceHost) if serviceHostKubernetesEnvVar != "" { return PlatformKubernetes, nil } + nomadJobName := os.Getenv(NomadJobName) if nomadJobName != "" { return PlatformNomad, nil diff --git a/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx b/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx index f39c08e32..132a8e398 100644 --- a/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx +++ b/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx @@ -24,6 +24,7 @@ export const UpgradeBEBannerWrapper = withHideOnExtension( const enabledPlatforms: Array = [ 'Docker Standalone', 'Docker Swarm', + 'Kubernetes', ]; function UpgradeBEBanner() { diff --git a/build/build_binary.sh b/build/build_binary.sh index eba453e58..e9a463403 100755 --- a/build/build_binary.sh +++ b/build/build_binary.sh @@ -1,21 +1,22 @@ #!/bin/sh - -set -x +DEBUG=${DEBUG:-""} +if [ -n "$DEBUG" ]; then + set -x +fi mkdir -p dist # populate tool versions -BUILDNUMBER="N/A" -CONTAINER_IMAGE_TAG="N/A" -NODE_VERSION="0" -YARN_VERSION="0" -WEBPACK_VERSION="0" -GO_VERSION="0" +BUILD_NUMBER=${BUILD_NUMBER:-"N/A"} +CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG:-"N/A"} +NODE_VERSION=${NODE_VERSION:-"0"} +YARN_VERSION=${YARN_VERSION:-"0"} +WEBPACK_VERSION=${WEBPACK_VERSION:-"0"} +GO_VERSION=${GO_VERSION:-"0"} # copy templates cp -r "./mustache-templates" "./dist" - cd api # the go get adds 8 seconds go get -t -d -v ./... @@ -25,7 +26,7 @@ GOOS=$1 GOARCH=$2 CGO_ENABLED=0 go build \ -trimpath \ --installsuffix cgo \ --ldflags "-s \ - --X 'github.com/portainer/portainer/api/build.BuildNumber=${BUILDNUMBER}' \ + --X 'github.com/portainer/portainer/api/build.BuildNumber=${BUILD_NUMBER}' \ --X 'github.com/portainer/portainer/api/build.ImageTag=${CONTAINER_IMAGE_TAG}' \ --X 'github.com/portainer/portainer/api/build.NodejsVersion=${NODE_VERSION}' \ --X 'github.com/portainer/portainer/api/build.YarnVersion=${YARN_VERSION}' \ From 9cca2998339f50e1d955808d0e437b7e9f04135c Mon Sep 17 00:00:00 2001 From: Matt Hook Date: Wed, 8 Mar 2023 17:23:34 +1300 Subject: [PATCH 25/55] fix(gotest): fix go tests as part of version bump to 2.19.0 (#8623) * bump version to 2.19 * fix broken go tests --- api/datastore/test_data/output_24_to_latest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index d5c2e3be1..be2d36f8d 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -945,6 +945,6 @@ } ], "version": { - "VERSION": "{\"SchemaVersion\":\"2.18.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" + "VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" } } \ No newline at end of file From fb6e26a302965e69a6761e6f19968682ef500c6f Mon Sep 17 00:00:00 2001 From: matias-portainer <104775949+matias-portainer@users.noreply.github.com> Date: Wed, 8 Mar 2023 19:34:57 -0300 Subject: [PATCH 26/55] fix(stacks): pass WorkingDir to deployer command EE-5142 (#8615) --- api/exec/compose_stack.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go index dd84bd6a2..bd9985cd4 100644 --- a/api/exec/compose_stack.go +++ b/api/exec/compose_stack.go @@ -82,9 +82,11 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S } err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.Options{ + WorkingDir: stack.ProjectPath, EnvFilePath: envFilePath, Host: url, }) + return errors.Wrap(err, "failed to remove a stack") } From 2cc80e5e5d68329069f497d44afbbfd8e8f8a31b Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Thu, 9 Mar 2023 22:06:57 +1300 Subject: [PATCH 27/55] refactor(GPU): refactor to colocate and simplify UI work [EE-5127] (#8593) * refactor to colocate and simplify * fix(insights): text size to match portainer views --------- Co-authored-by: testa113 --- app/docker/react/components/index.ts | 10 ++++++- .../docker-features-configuration.html | 16 +--------- .../react/components/environments.ts | 7 ++--- app/portainer/react/components/index.ts | 5 ---- .../views/endpoints/edit/endpoint.html | 17 +---------- .../components/InsightsBox/InsightsBox.tsx | 29 +++++++------------ .../docker/host/SetupView/GpusInsights.tsx | 29 +++++++++++++++++++ 7 files changed, 52 insertions(+), 61 deletions(-) create mode 100644 app/react/docker/host/SetupView/GpusInsights.tsx diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts index ee48460bc..a34888528 100644 --- a/app/docker/react/components/index.ts +++ b/app/docker/react/components/index.ts @@ -1,6 +1,7 @@ import angular from 'angular'; import { r2a } from '@/react-tools/react2angular'; +import { withControlledInput } from '@/react-tools/withControlledInput'; import { StackContainersDatatable } from '@/react/docker/stacks/ItemView/StackContainersDatatable'; import { ContainerQuickActions } from '@/react/docker/containers/components/ContainerQuickActions'; import { TemplateListDropdownAngular } from '@/react/docker/app-templates/TemplateListDropdown'; @@ -11,6 +12,8 @@ import { withReactQuery } from '@/react-tools/withReactQuery'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { DockerfileDetails } from '@/react/docker/images/ItemView/DockerfileDetails'; import { HealthStatus } from '@/react/docker/containers/ItemView/HealthStatus'; +import { GpusList } from '@/react/docker/host/SetupView/GpusList'; +import { GpusInsights } from '@/react/docker/host/SetupView/GpusInsights'; export const componentsModule = angular .module('portainer.docker.react.components', []) @@ -45,4 +48,9 @@ export const componentsModule = angular 'usedAllGpus', 'enableGpuManagement', ]) - ).name; + ) + .component( + 'gpusList', + r2a(withControlledInput(GpusList), ['value', 'onChange']) + ) + .component('gpusInsights', r2a(GpusInsights, [])).name; diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.html b/app/docker/views/docker-features-configuration/docker-features-configuration.html index 08ddd4f65..db3c6d697 100644 --- a/app/docker/views/docker-features-configuration/docker-features-configuration.html +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.html @@ -151,21 +151,7 @@
Other
- +
- +
diff --git a/app/react/components/InsightsBox/InsightsBox.tsx b/app/react/components/InsightsBox/InsightsBox.tsx index 19ea3a79d..552e96042 100644 --- a/app/react/components/InsightsBox/InsightsBox.tsx +++ b/app/react/components/InsightsBox/InsightsBox.tsx @@ -1,7 +1,6 @@ import clsx from 'clsx'; import { Lightbulb, X } from 'lucide-react'; -import { ReactNode, useMemo } from 'react'; -import sanitize from 'sanitize-html'; +import { ReactNode } from 'react'; import { useStore } from 'zustand'; import { Button } from '@@/buttons'; @@ -15,25 +14,11 @@ export type Props = { insightCloseId?: string; // set if you want to be able to close the box and not show it again }; -export function InsightsBox({ - header, - content, - setHtmlContent, - insightCloseId, -}: Props) { +export function InsightsBox({ header, content, insightCloseId }: Props) { // allow to close the box and not show it again in local storage with zustand const { addInsightIDClosed, isClosed } = useStore(insightStore); const isInsightClosed = isClosed(insightCloseId); - // allow angular views to set html messages for the insights box - const htmlContent = useMemo(() => { - if (setHtmlContent && typeof content === 'string') { - // eslint-disable-next-line react/no-danger - return
; - } - return null; - }, [setHtmlContent, content]); - if (isInsightClosed) { return null; } @@ -44,10 +29,16 @@ export function InsightsBox({
-

+

{header}

-
{htmlContent || content}
+
{content}
{insightCloseId && (
- + items.find((item) => item.Id === id)) From 8f42af49e88439d31bc0ef81c24a84022f7f3d12 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Fri, 10 Mar 2023 16:52:09 +1300 Subject: [PATCH 30/55] fix(annotation): update wording/styling [EE-5158] (#8642) Co-authored-by: testa113 --- .../views/applications/create/createApplication.html | 2 +- app/react/kubernetes/annotations/AnnotationsBeTeaser.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 31c8a704c..79179ac3c 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -212,7 +212,7 @@
-
+
diff --git a/app/react/kubernetes/annotations/AnnotationsBeTeaser.tsx b/app/react/kubernetes/annotations/AnnotationsBeTeaser.tsx index 0b00a7fe2..9bdb4ecf9 100644 --- a/app/react/kubernetes/annotations/AnnotationsBeTeaser.tsx +++ b/app/react/kubernetes/annotations/AnnotationsBeTeaser.tsx @@ -14,7 +14,7 @@ export function AnnotationsBeTeaser() { message={
- You can specify{' '} + Allows specifying of{' '}
} - setHtmlMessage />
From 29648f517b896fc7dc6c1d4e17fd92443270f8e9 Mon Sep 17 00:00:00 2001 From: Matt Hook Date: Mon, 13 Mar 2023 09:44:27 +1300 Subject: [PATCH 31/55] reduce throttling in the kube client (#8630) --- api/kubernetes/cli/client.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index fc77a7786..c2316c0d7 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -17,6 +17,11 @@ import ( "k8s.io/client-go/tools/clientcmd" ) +const ( + DefaultKubeClientQPS = 30 + DefaultKubeClientBurst = 100 +) + type ( // ClientFactory is used to create Kubernetes clients ClientFactory struct { @@ -113,6 +118,9 @@ func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, k return nil, err } + cliConfig.QPS = DefaultKubeClientQPS + cliConfig.Burst = DefaultKubeClientBurst + cli, err := kubernetes.NewForConfig(cliConfig) if err != nil { return nil, err @@ -198,7 +206,10 @@ func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernete if err != nil { return nil, err } + config.Insecure = true + config.QPS = DefaultKubeClientQPS + config.Burst = DefaultKubeClientBurst config.Wrap(func(rt http.RoundTripper) http.RoundTripper { return &agentHeaderRoundTripper{ @@ -217,6 +228,9 @@ func buildLocalClient() (*kubernetes.Clientset, error) { return nil, err } + config.QPS = DefaultKubeClientQPS + config.Burst = DefaultKubeClientBurst + return kubernetes.NewForConfig(config) } From 77b49ae9c5990103d1c3400a9870c6fae6ecd43a Mon Sep 17 00:00:00 2001 From: Prabhat Khera <91852476+prabhat-org@users.noreply.github.com> Date: Mon, 13 Mar 2023 11:05:51 +1300 Subject: [PATCH 32/55] fix typo in delete image modal dialog (#8621) --- app/docker/views/images/imagesController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/docker/views/images/imagesController.js b/app/docker/views/images/imagesController.js index 5862059cb..c07cf90ed 100644 --- a/app/docker/views/images/imagesController.js +++ b/app/docker/views/images/imagesController.js @@ -167,6 +167,6 @@ function confirmImageForceRemoval() { title: 'Are you sure?', modalType: ModalType.Destructive, message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.', - confirmButton: buildConfirmButton('Remote the image', 'danger'), + confirmButton: buildConfirmButton('Remove the image', 'danger'), }); } From 37f382d2865a764305749a97c532f78adbdff7d0 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Mon, 13 Mar 2023 13:57:11 +1300 Subject: [PATCH 33/55] fix(kube): check for ns on enter [EE-5160] (#8647) Co-authored-by: testa113 --- app/kubernetes/__module.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index ab05117ef..5ebe986fb 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -46,6 +46,8 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment && endpoint.Status === EnvironmentStatus.Down) { throw new Error('Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.'); } + + await KubernetesNamespaceService.get(); } catch (e) { let params = {}; From 621a01ba3b2b17632df8a68f5c5b97fe97929bc2 Mon Sep 17 00:00:00 2001 From: matias-portainer <104775949+matias-portainer@users.noreply.github.com> Date: Mon, 13 Mar 2023 09:01:27 -0300 Subject: [PATCH 34/55] fix(upgrade): remove yellow upgrade banner EE-5141 (#8640) --- api/datastore/test_data/output_24_to_latest.json | 6 ++---- api/http/handler/users/user_update.go | 10 ++-------- api/portainer.go | 2 -- .../components/theme/theme-settings.controller.js | 10 ---------- app/portainer/components/theme/theme-settings.html | 10 ---------- app/portainer/users/types.ts | 1 - app/react-tools/test-mocks.ts | 1 - .../UsersSelector/UsersSelector.mocks.ts | 1 - .../CreateTeamForm/CreateTeamForm.mocks.ts | 2 -- .../sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx | 14 ++------------ app/react/test-utils/withUserProvider.tsx | 1 - 11 files changed, 6 insertions(+), 52 deletions(-) diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index be2d36f8d..6b0b59c70 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -905,8 +905,7 @@ }, "Role": 1, "ThemeSettings": { - "color": "", - "subtleUpgradeButton": false + "color": "" }, "TokenIssueAt": 0, "UserTheme": "", @@ -936,8 +935,7 @@ }, "Role": 1, "ThemeSettings": { - "color": "", - "subtleUpgradeButton": false + "color": "" }, "TokenIssueAt": 0, "UserTheme": "", diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go index 43d8b4dab..844f38806 100644 --- a/api/http/handler/users/user_update.go +++ b/api/http/handler/users/user_update.go @@ -18,8 +18,6 @@ import ( type themePayload struct { // Color represents the color theme of the UI Color *string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"` - // SubtleUpgradeButton indicates if the upgrade banner should be displayed in a subtle way - SubtleUpgradeButton *bool `json:"subtleUpgradeButton" example:"false"` } type userUpdatePayload struct { @@ -33,11 +31,11 @@ type userUpdatePayload struct { func (payload *userUpdatePayload) Validate(r *http.Request) error { if govalidator.Contains(payload.Username, " ") { - return errors.New("Invalid username. Must not contain any whitespace") + return errors.New("invalid username. Must not contain any whitespace") } if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 { - return errors.New("Invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") + return errors.New("invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") } return nil } @@ -120,10 +118,6 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http if payload.Theme.Color != nil { user.ThemeSettings.Color = *payload.Theme.Color } - - if payload.Theme.SubtleUpgradeButton != nil { - user.ThemeSettings.SubtleUpgradeButton = *payload.Theme.SubtleUpgradeButton - } } if payload.Role != 0 { diff --git a/api/portainer.go b/api/portainer.go index e6ed05361..f4133047d 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1286,8 +1286,6 @@ type ( UserThemeSettings struct { // Color represents the color theme of the UI Color string `json:"color" example:"dark" enums:"dark,light,highcontrast,auto"` - // SubtleUpgradeButton indicates if the upgrade banner should be displayed in a subtle way - SubtleUpgradeButton bool `json:"subtleUpgradeButton"` } // Webhook represents a url webhook that can be used to update a service diff --git a/app/portainer/components/theme/theme-settings.controller.js b/app/portainer/components/theme/theme-settings.controller.js index 3c6bb47eb..c0d5b9922 100644 --- a/app/portainer/components/theme/theme-settings.controller.js +++ b/app/portainer/components/theme/theme-settings.controller.js @@ -13,7 +13,6 @@ export default class ThemeSettingsController { this.UserService = UserService; this.setThemeColor = this.setThemeColor.bind(this); - this.setSubtleUpgradeButton = this.setSubtleUpgradeButton.bind(this); } async setThemeColor(color) { @@ -29,13 +28,6 @@ export default class ThemeSettingsController { }); } - async setSubtleUpgradeButton(value) { - return this.$async(async () => { - this.state.subtleUpgradeButton = value; - this.updateThemeSettings({ subtleUpgradeButton: value }); - }); - } - async updateThemeSettings(theme) { try { if (!this.state.isDemo) { @@ -57,7 +49,6 @@ export default class ThemeSettingsController { userId: null, themeColor: 'auto', isDemo: state.application.demoEnvironment.enabled, - subtleUpgradeButton: false, }; this.state.availableThemes = options; @@ -67,7 +58,6 @@ export default class ThemeSettingsController { const user = await this.UserService.user(this.state.userId); this.state.themeColor = user.ThemeSettings.color || this.state.themeColor; - this.state.subtleUpgradeButton = !!user.ThemeSettings.subtleUpgradeButton; } catch (err) { notifyError('Failure', err, 'Unable to get user details'); } diff --git a/app/portainer/components/theme/theme-settings.html b/app/portainer/components/theme/theme-settings.html index e3fa245e8..58f2dc752 100644 --- a/app/portainer/components/theme/theme-settings.html +++ b/app/portainer/components/theme/theme-settings.html @@ -9,16 +9,6 @@ Dark and High-contrast theme are experimental. Some UI components might not display properly.

- -
- -
diff --git a/app/portainer/users/types.ts b/app/portainer/users/types.ts index 5b5a9ecf4..4b47362b8 100644 --- a/app/portainer/users/types.ts +++ b/app/portainer/users/types.ts @@ -22,6 +22,5 @@ export type User = { }; ThemeSettings: { color: 'dark' | 'light' | 'highcontrast' | 'auto'; - subtleUpgradeButton: boolean; }; }; diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts index fc8d3fe39..6efc2bb0b 100644 --- a/app/react-tools/test-mocks.ts +++ b/app/react-tools/test-mocks.ts @@ -19,7 +19,6 @@ export function createMockUsers( PortainerAuthorizations: {}, ThemeSettings: { color: 'auto', - subtleUpgradeButton: false, }, })); } diff --git a/app/react/components/UsersSelector/UsersSelector.mocks.ts b/app/react/components/UsersSelector/UsersSelector.mocks.ts index 2ebc35b7f..a245054e3 100644 --- a/app/react/components/UsersSelector/UsersSelector.mocks.ts +++ b/app/react/components/UsersSelector/UsersSelector.mocks.ts @@ -26,7 +26,6 @@ export function createMockUser(id: number, username: string): UserViewModel { AuthenticationMethod: '', ThemeSettings: { color: 'auto', - subtleUpgradeButton: false, }, }; } diff --git a/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.mocks.ts b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.mocks.ts index e2e196cea..aa599082d 100644 --- a/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.mocks.ts +++ b/app/react/portainer/users/teams/ListView/CreateTeamForm/CreateTeamForm.mocks.ts @@ -22,7 +22,6 @@ export function mockExampleData() { Role: 2, ThemeSettings: { color: 'auto', - subtleUpgradeButton: false, }, EndpointAuthorizations: {}, PortainerAuthorizations: { @@ -50,7 +49,6 @@ export function mockExampleData() { Role: 2, ThemeSettings: { color: 'auto', - subtleUpgradeButton: false, }, EndpointAuthorizations: {}, PortainerAuthorizations: { diff --git a/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx b/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx index 132a8e398..17ada31dd 100644 --- a/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx +++ b/app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx @@ -65,19 +65,13 @@ function UpgradeBEBanner() { return null; } - const subtleButton = userQuery.data.ThemeSettings.subtleUpgradeButton; - return ( <>
From 347f66b1f1e851f64fea70f87e2b1fc61edc78d0 Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Tue, 14 Mar 2023 13:28:20 -0300 Subject: [PATCH 37/55] fix(edge): fix status inspect error message EE-5190 (#8661) --- .../endpointedge/endpoint_edgestatus_inspect.go | 10 ++++------ .../endpointedge/endpointedge_status_inspect_test.go | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go b/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go index c96437604..23d747365 100644 --- a/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go +++ b/api/http/handler/endpointedge/endpoint_edgestatus_inspect.go @@ -81,16 +81,14 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http } if _, ok := handler.DataStore.Endpoint().Heartbeat(portainer.EndpointID(endpointID)); !ok { - return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", nil) + // EE-5910 + return httperror.Forbidden("Permission denied to access environment", errors.New("the device has not been trusted yet")) } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err != nil { - if handler.DataStore.IsErrObjectNotFound(err) { - return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) - } - - return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + // EE-5910 + return httperror.Forbidden("Permission denied to access environment", errors.New("the device has not been trusted yet")) } err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint) diff --git a/api/http/handler/endpointedge/endpointedge_status_inspect_test.go b/api/http/handler/endpointedge/endpointedge_status_inspect_test.go index 442f86807..91c32a0f6 100644 --- a/api/http/handler/endpointedge/endpointedge_status_inspect_test.go +++ b/api/http/handler/endpointedge/endpointedge_status_inspect_test.go @@ -30,7 +30,7 @@ var endpointTestCases = []endpointTestCase{ { portainer.Endpoint{}, portainer.EndpointRelation{}, - http.StatusNotFound, + http.StatusForbidden, }, { portainer.Endpoint{ @@ -43,7 +43,7 @@ var endpointTestCases = []endpointTestCase{ portainer.EndpointRelation{ EndpointID: -1, }, - http.StatusNotFound, + http.StatusForbidden, }, { portainer.Endpoint{ From 2b17cb91049e862951baa983383fa28f8cf8b252 Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Tue, 14 Mar 2023 20:11:28 -0300 Subject: [PATCH 38/55] fix(kubernetes): fix data-race in GetKubeClient() EE-4436 (#8498) --- api/kubernetes/cli/access.go | 5 +-- api/kubernetes/cli/access_test.go | 2 -- api/kubernetes/cli/client.go | 54 ++++++++++++++-------------- api/kubernetes/cli/namespace_test.go | 10 ------ 4 files changed, 31 insertions(+), 40 deletions(-) diff --git a/api/kubernetes/cli/access.go b/api/kubernetes/cli/access.go index 729e3c801..20a8e070e 100644 --- a/api/kubernetes/cli/access.go +++ b/api/kubernetes/cli/access.go @@ -12,8 +12,8 @@ import ( // NamespaceAccessPoliciesDeleteNamespace removes stored policies associated with a given namespace func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error { - kcl.lock.Lock() - defer kcl.lock.Unlock() + kcl.mu.Lock() + defer kcl.mu.Unlock() policies, err := kcl.GetNamespaceAccessPolicies() if err != nil { @@ -42,6 +42,7 @@ func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNam if err != nil { return nil, err } + return policies, nil } diff --git a/api/kubernetes/cli/access_test.go b/api/kubernetes/cli/access_test.go index db250546c..09bb28d9e 100644 --- a/api/kubernetes/cli/access_test.go +++ b/api/kubernetes/cli/access_test.go @@ -2,7 +2,6 @@ package cli import ( "context" - "sync" "testing" portainer "github.com/portainer/portainer/api" @@ -40,7 +39,6 @@ func Test_NamespaceAccessPoliciesDeleteNamespace_updatesPortainerConfig_whenConf k := &KubeClient{ cli: kfake.NewSimpleClientset(), instanceID: "instance", - lock: &sync.Mutex{}, } config := &ktypes.ConfigMap{ diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index c2316c0d7..0b2157c73 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -7,7 +7,6 @@ import ( "sync" "time" - cmap "github.com/orcaman/concurrent-map" "github.com/patrickmn/go-cache" "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" @@ -29,16 +28,17 @@ type ( reverseTunnelService portainer.ReverseTunnelService signatureService portainer.DigitalSignatureService instanceID string - endpointClients cmap.ConcurrentMap + endpointClients map[string]*KubeClient endpointProxyClients *cache.Cache AddrHTTPS string + mu sync.Mutex } // KubeClient represent a service used to execute Kubernetes operations KubeClient struct { cli kubernetes.Interface instanceID string - lock *sync.Mutex + mu sync.Mutex } ) @@ -57,7 +57,7 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers signatureService: signatureService, reverseTunnelService: reverseTunnelService, instanceID: instanceID, - endpointClients: cmap.New(), + endpointClients: make(map[string]*KubeClient), endpointProxyClients: cache.New(timeout, timeout), AddrHTTPS: addrHTTPS, }, nil @@ -69,50 +69,58 @@ func (factory *ClientFactory) GetInstanceID() (instanceID string) { // Remove the cached kube client so a new one can be created func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID) { - factory.endpointClients.Remove(strconv.Itoa(int(endpointID))) + factory.mu.Lock() + delete(factory.endpointClients, strconv.Itoa(int(endpointID))) + factory.mu.Unlock() } // GetKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found. // If no client is registered, it will create a new client, register it, and returns it. -func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) { +func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { + factory.mu.Lock() + defer factory.mu.Unlock() + key := strconv.Itoa(int(endpoint.ID)) - client, ok := factory.endpointClients.Get(key) + client, ok := factory.endpointClients[key] if !ok { - client, err := factory.createCachedAdminKubeClient(endpoint) + var err error + + client, err = factory.createCachedAdminKubeClient(endpoint) if err != nil { return nil, err } - factory.endpointClients.Set(key, client) - return client, nil + factory.endpointClients[key] = client } - return client.(portainer.KubeClient), nil + return client, nil } // GetProxyKubeClient retrieves a KubeClient from the cache. You should be // calling SetProxyKubeClient before first. It is normally, called the // kubernetes middleware. -func (factory *ClientFactory) GetProxyKubeClient(endpointID, token string) (portainer.KubeClient, bool) { +func (factory *ClientFactory) GetProxyKubeClient(endpointID, token string) (*KubeClient, bool) { client, ok := factory.endpointProxyClients.Get(endpointID + "." + token) if !ok { return nil, false } - return client.(portainer.KubeClient), true + + return client.(*KubeClient), true } // SetProxyKubeClient stores a kubeclient in the cache. -func (factory *ClientFactory) SetProxyKubeClient(endpointID, token string, cli portainer.KubeClient) { +func (factory *ClientFactory) SetProxyKubeClient(endpointID, token string, cli *KubeClient) { factory.endpointProxyClients.Set(endpointID+"."+token, cli, 0) } // CreateKubeClientFromKubeConfig creates a KubeClient from a clusterID, and // Kubernetes config. -func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, kubeConfig []byte) (portainer.KubeClient, error) { +func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, kubeConfig []byte) (*KubeClient, error) { config, err := clientcmd.NewClientConfigFromBytes([]byte(kubeConfig)) if err != nil { return nil, err } + cliConfig, err := config.ClientConfig() if err != nil { return nil, err @@ -126,28 +134,22 @@ func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, k return nil, err } - kubecli := &KubeClient{ + return &KubeClient{ cli: cli, instanceID: factory.instanceID, - lock: &sync.Mutex{}, - } - - return kubecli, nil + }, nil } -func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) { +func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) { cli, err := factory.CreateClient(endpoint) if err != nil { return nil, err } - kubecli := &KubeClient{ + return &KubeClient{ cli: cli, instanceID: factory.instanceID, - lock: &sync.Mutex{}, - } - - return kubecli, nil + }, nil } // CreateClient returns a pointer to a new Clientset instance diff --git a/api/kubernetes/cli/namespace_test.go b/api/kubernetes/cli/namespace_test.go index 9d3647877..ea4821bab 100644 --- a/api/kubernetes/cli/namespace_test.go +++ b/api/kubernetes/cli/namespace_test.go @@ -3,7 +3,6 @@ package cli import ( "context" "strconv" - "sync" "testing" portainer "github.com/portainer/portainer/api" @@ -19,7 +18,6 @@ func Test_ToggleSystemState(t *testing.T) { kcl := &KubeClient{ cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, true) @@ -37,12 +35,10 @@ func Test_ToggleSystemState(t *testing.T) { kcl := &KubeClient{ cli: kfake.NewSimpleClientset(), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, true) assert.Error(t, err) - }) t.Run("if called with the same state, should skip (exit without error)", func(t *testing.T) { @@ -61,7 +57,6 @@ func Test_ToggleSystemState(t *testing.T) { systemNamespaceLabel: strconv.FormatBool(test.isSystem), }}}), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, test.isSystem) @@ -81,7 +76,6 @@ func Test_ToggleSystemState(t *testing.T) { kcl := &KubeClient{ cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, true) @@ -102,7 +96,6 @@ func Test_ToggleSystemState(t *testing.T) { kcl := &KubeClient{ cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}}), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, false) @@ -125,7 +118,6 @@ func Test_ToggleSystemState(t *testing.T) { systemNamespaceLabel: "true", }}}), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, false) @@ -159,7 +151,6 @@ func Test_ToggleSystemState(t *testing.T) { kcl := &KubeClient{ cli: kfake.NewSimpleClientset(namespace, config), instanceID: "instance", - lock: &sync.Mutex{}, } err := kcl.ToggleSystemState(nsName, true) @@ -178,6 +169,5 @@ func Test_ToggleSystemState(t *testing.T) { actualPolicies, err := kcl.GetNamespaceAccessPolicies() assert.NoError(t, err, "failed to fetch policies") assert.Equal(t, expectedPolicies, actualPolicies) - }) } From 795e6a5b3c19f1404f541118bc33ff4d562aa246 Mon Sep 17 00:00:00 2001 From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com> Date: Wed, 15 Mar 2023 12:54:09 +1300 Subject: [PATCH 39/55] fix(stack/git): unable to move git repository error [EE-5144] (#8618) --- api/go.sum | 6 ------ api/http/handler/stacks/stack_update_git_redeploy.go | 8 -------- 2 files changed, 14 deletions(-) diff --git a/api/go.sum b/api/go.sum index e8019a424..206c3bdd1 100644 --- a/api/go.sum +++ b/api/go.sum @@ -342,12 +342,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/portainer/docker-compose-wrapper v0.0.0-20221215210951-2c30d1b17a27 h1:PceCpp86SDYb3lZHT4KpuBCkmcJMW5x1qrdFNEfAdUo= -github.com/portainer/docker-compose-wrapper v0.0.0-20221215210951-2c30d1b17a27/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww= -github.com/portainer/docker-compose-wrapper v0.0.0-20230209071700-ee11af9c546a h1:4VGM1OH15fqm5rgki0eLF6vND/NxHfoPt3CA6/YdA0k= -github.com/portainer/docker-compose-wrapper v0.0.0-20230209071700-ee11af9c546a/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww= -github.com/portainer/docker-compose-wrapper v0.0.0-20230209082344-8a5b52de366f h1:z/lmLhZMMSIwg70Ap1rPluXNe1vQXH9gfK9K/ols4JA= -github.com/portainer/docker-compose-wrapper v0.0.0-20230209082344-8a5b52de366f/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww= github.com/portainer/docker-compose-wrapper v0.0.0-20230301083819-3dbc6abf1ce7 h1:/i985KPNw0KvVtLhTEPUa86aJMtun5ZPOyFCJzdY+dY= github.com/portainer/docker-compose-wrapper v0.0.0-20230301083819-3dbc6abf1ce7/go.mod h1:03UmPLyjiPUexGJuW20mQXvmsoSpeErvMlItJGtq/Ww= github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a h1:B0z3skIMT+OwVNJPQhKp52X+9OWW6A9n5UWig3lHBJk= diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index 3eb9d3a9b..13e0bcb3f 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -1,7 +1,6 @@ package stacks import ( - "fmt" "net/http" "time" @@ -10,7 +9,6 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/git" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" @@ -137,12 +135,6 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) } } - backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath) - err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath) - if err != nil { - return httperror.InternalServerError("Unable to move git repository directory", err) - } - repositoryUsername := "" repositoryPassword := "" if payload.RepositoryAuthentication { From ea03024fbcfac0db1c2dc843bce018335754267d Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Wed, 15 Mar 2023 14:53:38 -0300 Subject: [PATCH 40/55] fix(edgegroup): fix data race in edge group update EE-4441 (#8523) --- .../handler/edgegroups/edgegroup_create.go | 14 +- .../handler/edgegroups/edgegroup_update.go | 180 +++++++++--------- api/http/handler/edgegroups/handler.go | 16 +- 3 files changed, 114 insertions(+), 96 deletions(-) diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go index 68340d7c0..18797f14a 100644 --- a/api/http/handler/edgegroups/edgegroup_create.go +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -6,7 +6,6 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/endpointutils" @@ -26,12 +25,15 @@ func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { return errors.New("invalid Edge group name") } + if payload.Dynamic && len(payload.TagIDs) == 0 { return errors.New("tagIDs is mandatory for a dynamic Edge group") } + if !payload.Dynamic && len(payload.Endpoints) == 0 { return errors.New("environment is mandatory for a static Edge group") } + return nil } @@ -56,7 +58,6 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) } var edgeGroup *portainer.EdgeGroup - err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { edgeGroups, err := tx.EdgeGroup().EdgeGroups() if err != nil { @@ -101,13 +102,6 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) return nil }) - if err != nil { - if httpErr, ok := err.(*httperror.HandlerError); ok { - return httpErr - } - return httperror.InternalServerError("Unexpected error", err) - } - - return response.JSON(w, edgeGroup) + return txResponse(w, edgeGroup, err) } diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go index ee1b8b3a5..0cffc1201 100644 --- a/api/http/handler/edgegroups/edgegroup_update.go +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -6,8 +6,8 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" - "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/slices" @@ -27,12 +27,15 @@ func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { return errors.New("invalid Edge group name") } + if payload.Dynamic && len(payload.TagIDs) == 0 { return errors.New("tagIDs is mandatory for a dynamic Edge group") } + if !payload.Dynamic && len(payload.Endpoints) == 0 { return errors.New("environments is mandatory for a static Edge group") } + return nil } @@ -62,128 +65,135 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) return httperror.BadRequest("Invalid request payload", err) } - edgeGroup, err := handler.DataStore.EdgeGroup().EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) - if handler.DataStore.IsErrObjectNotFound(err) { - return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err) - } else if err != nil { - return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err) - } - - if payload.Name != "" { - edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() - if err != nil { - return httperror.InternalServerError("Unable to retrieve Edge groups from the database", err) - } - for _, edgeGroup := range edgeGroups { - if edgeGroup.Name == payload.Name && edgeGroup.ID != portainer.EdgeGroupID(edgeGroupID) { - return httperror.BadRequest("Edge group name must be unique", errors.New("edge group name must be unique")) - } + var edgeGroup *portainer.EdgeGroup + err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + edgeGroup, err = tx.EdgeGroup().EdgeGroup(portainer.EdgeGroupID(edgeGroupID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an Edge group with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an Edge group with the specified identifier inside the database", err) } - edgeGroup.Name = payload.Name - } - endpoints, err := handler.DataStore.Endpoint().Endpoints() - if err != nil { - return httperror.InternalServerError("Unable to retrieve environments from database", err) - } - - endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() - if err != nil { - return httperror.InternalServerError("Unable to retrieve environment groups from database", err) - } - - oldRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) - - edgeGroup.Dynamic = payload.Dynamic - if edgeGroup.Dynamic { - edgeGroup.TagIDs = payload.TagIDs - } else { - endpointIDs := []portainer.EndpointID{} - for _, endpointID := range payload.Endpoints { - endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + if payload.Name != "" { + edgeGroups, err := tx.EdgeGroup().EdgeGroups() if err != nil { - return httperror.InternalServerError("Unable to retrieve environment from the database", err) + return httperror.InternalServerError("Unable to retrieve Edge groups from the database", err) } - if endpointutils.IsEdgeEndpoint(endpoint) { - endpointIDs = append(endpointIDs, endpoint.ID) + for _, edgeGroup := range edgeGroups { + if edgeGroup.Name == payload.Name && edgeGroup.ID != portainer.EdgeGroupID(edgeGroupID) { + return httperror.BadRequest("Edge group name must be unique", errors.New("edge group name must be unique")) + } } + + edgeGroup.Name = payload.Name } - edgeGroup.Endpoints = endpointIDs - } - if payload.PartialMatch != nil { - edgeGroup.PartialMatch = *payload.PartialMatch - } - - err = handler.DataStore.EdgeGroup().UpdateEdgeGroup(edgeGroup.ID, edgeGroup) - if err != nil { - return httperror.InternalServerError("Unable to persist Edge group changes inside the database", err) - } - - newRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) - endpointsToUpdate := append(newRelatedEndpoints, oldRelatedEndpoints...) - - edgeJobs, err := handler.DataStore.EdgeJob().EdgeJobs() - if err != nil { - return httperror.InternalServerError("Unable to fetch Edge jobs", err) - } - - for _, endpointID := range endpointsToUpdate { - err = handler.updateEndpointStacks(endpointID) + endpoints, err := tx.Endpoint().Endpoints() if err != nil { - return httperror.InternalServerError("Unable to persist Environment relation changes inside the database", err) + return httperror.InternalServerError("Unable to retrieve environments from database", err) } - endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + endpointGroups, err := tx.EndpointGroup().EndpointGroups() if err != nil { - return httperror.InternalServerError("Unable to get Environment from database", err) + return httperror.InternalServerError("Unable to retrieve environment groups from database", err) } - if !endpointutils.IsEdgeEndpoint(endpoint) { - continue - } + oldRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) - var operation string - if slices.Contains(newRelatedEndpoints, endpointID) { - operation = "add" - } else if slices.Contains(oldRelatedEndpoints, endpointID) { - operation = "remove" + edgeGroup.Dynamic = payload.Dynamic + if edgeGroup.Dynamic { + edgeGroup.TagIDs = payload.TagIDs } else { - continue + endpointIDs := []portainer.EndpointID{} + for _, endpointID := range payload.Endpoints { + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + return httperror.InternalServerError("Unable to retrieve environment from the database", err) + } + + if endpointutils.IsEdgeEndpoint(endpoint) { + endpointIDs = append(endpointIDs, endpoint.ID) + } + } + edgeGroup.Endpoints = endpointIDs } - err = handler.updateEndpointEdgeJobs(edgeGroup.ID, endpoint, edgeJobs, operation) + if payload.PartialMatch != nil { + edgeGroup.PartialMatch = *payload.PartialMatch + } + + err = tx.EdgeGroup().UpdateEdgeGroup(edgeGroup.ID, edgeGroup) if err != nil { - return httperror.InternalServerError("Unable to persist Environment Edge Jobs changes inside the database", err) + return httperror.InternalServerError("Unable to persist Edge group changes inside the database", err) } - } - return response.JSON(w, edgeGroup) + newRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) + endpointsToUpdate := append(newRelatedEndpoints, oldRelatedEndpoints...) + + edgeJobs, err := tx.EdgeJob().EdgeJobs() + if err != nil { + return httperror.InternalServerError("Unable to fetch Edge jobs", err) + } + + for _, endpointID := range endpointsToUpdate { + err = handler.updateEndpointStacks(tx, endpointID) + if err != nil { + return httperror.InternalServerError("Unable to persist Environment relation changes inside the database", err) + } + + endpoint, err := tx.Endpoint().Endpoint(endpointID) + if err != nil { + return httperror.InternalServerError("Unable to get Environment from database", err) + } + + if !endpointutils.IsEdgeEndpoint(endpoint) { + continue + } + + var operation string + if slices.Contains(newRelatedEndpoints, endpointID) { + operation = "add" + } else if slices.Contains(oldRelatedEndpoints, endpointID) { + operation = "remove" + } else { + continue + } + + err = handler.updateEndpointEdgeJobs(edgeGroup.ID, endpoint, edgeJobs, operation) + if err != nil { + return httperror.InternalServerError("Unable to persist Environment Edge Jobs changes inside the database", err) + } + } + + return nil + }) + + return txResponse(w, edgeGroup, err) } -func (handler *Handler) updateEndpointStacks(endpointID portainer.EndpointID) error { - relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID) +func (handler *Handler) updateEndpointStacks(tx dataservices.DataStoreTx, endpointID portainer.EndpointID) error { + relation, err := tx.EndpointRelation().EndpointRelation(endpointID) if err != nil { return err } - endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) + endpoint, err := tx.Endpoint().Endpoint(endpointID) if err != nil { return err } - endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID) + endpointGroup, err := tx.EndpointGroup().EndpointGroup(endpoint.GroupID) if err != nil { return err } - edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() + edgeGroups, err := tx.EdgeGroup().EdgeGroups() if err != nil { return err } - edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() + edgeStacks, err := tx.EdgeStack().EdgeStacks() if err != nil { return err } @@ -197,7 +207,7 @@ func (handler *Handler) updateEndpointStacks(endpointID portainer.EndpointID) er relation.EdgeStacks = edgeStackSet - return handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) + return tx.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) } func (handler *Handler) updateEndpointEdgeJobs(edgeGroupID portainer.EdgeGroupID, endpoint *portainer.Endpoint, edgeJobs []portainer.EdgeJob, operation string) error { diff --git a/api/http/handler/edgegroups/handler.go b/api/http/handler/edgegroups/handler.go index 5b950a581..3e1a44e74 100644 --- a/api/http/handler/edgegroups/handler.go +++ b/api/http/handler/edgegroups/handler.go @@ -3,11 +3,13 @@ package edgegroups import ( "net/http" - "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/security" + + "github.com/gorilla/mux" ) // Handler is the HTTP handler used to handle environment(endpoint) group operations. @@ -34,3 +36,15 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeGroupDelete)))).Methods(http.MethodDelete) return h } + +func txResponse(w http.ResponseWriter, r any, err error) *httperror.HandlerError { + if err != nil { + if httpErr, ok := err.(*httperror.HandlerError); ok { + return httpErr + } + + return httperror.InternalServerError("Unexpected error", err) + } + + return response.JSON(w, r) +} From 44582732bb178bd375ab03c4937d2591ed15e1a0 Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Wed, 15 Mar 2023 15:16:41 -0300 Subject: [PATCH 41/55] fix(home): exclude snapshots from the home page to improve the loading times EE-5154 (#8626) --- app/portainer/views/auth/authController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index da92669ae..a03e19109 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -130,7 +130,7 @@ class AuthenticationController { async checkForEndpointsAsync() { try { const isAdmin = this.Authentication.isAdmin(); - const endpoints = await getEnvironments({ limit: 1 }); + const endpoints = await getEnvironments({ limit: 1, query: { excludeSnapshots: true } }); if (this.Authentication.getUserDetails().forceChangePassword) { return this.$state.go('portainer.account'); From 6ee5cc6a56dd6181d9165d6eb5bb5c78f5eb5f38 Mon Sep 17 00:00:00 2001 From: Prabhat Khera <91852476+prabhat-org@users.noreply.github.com> Date: Thu, 16 Mar 2023 10:10:37 +1300 Subject: [PATCH 42/55] fix(ui): namespace cache refresh on reload EE-5155 (#8644) --- .../helm/helm-templates/helm-templates.controller.js | 4 +++- app/kubernetes/services/namespaceService.js | 11 ++++++----- app/kubernetes/services/resourcePoolService.js | 4 ++-- .../views/applications/applicationsController.js | 2 +- .../create/createApplicationController.js | 5 ++++- .../create/createConfigurationController.js | 5 ++++- app/kubernetes/views/configure/configureController.js | 5 ++++- app/kubernetes/views/deploy/deployController.js | 3 ++- .../views/resource-pools/resourcePoolsController.js | 4 ++-- 9 files changed, 28 insertions(+), 15 deletions(-) diff --git a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js b/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js index 957dcc911..7af3f3436 100644 --- a/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js +++ b/app/kubernetes/components/helm/helm-templates/helm-templates.controller.js @@ -141,7 +141,9 @@ export default class HelmTemplatesController { try { const resourcePools = await this.KubernetesResourcePoolService.get(); - const nonSystemNamespaces = resourcePools.filter((resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name)); + const nonSystemNamespaces = resourcePools.filter( + (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active' + ); this.state.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1)); this.state.resourcePool = this.state.resourcePools[0]; } catch (err) { diff --git a/app/kubernetes/services/namespaceService.js b/app/kubernetes/services/namespaceService.js index fe54c1800..6d82c3e3c 100644 --- a/app/kubernetes/services/namespaceService.js +++ b/app/kubernetes/services/namespaceService.js @@ -81,18 +81,19 @@ class KubernetesNamespaceService { } } - async get(name) { + async get(name, refreshCache = false) { if (name) { return this.$async(this.getAsync, name); } const cachedAllowedNamespaces = this.LocalStorage.getAllowedNamespaces(); - if (cachedAllowedNamespaces) { - updateNamespaces(cachedAllowedNamespaces); - return cachedAllowedNamespaces; - } else { + if (!cachedAllowedNamespaces || refreshCache) { const allowedNamespaces = await this.getAllAsync(); this.LocalStorage.storeAllowedNamespaces(allowedNamespaces); + updateNamespaces(allowedNamespaces); return allowedNamespaces; + } else { + updateNamespaces(cachedAllowedNamespaces); + return cachedAllowedNamespaces; } } diff --git a/app/kubernetes/services/resourcePoolService.js b/app/kubernetes/services/resourcePoolService.js index 639c44db1..1b0f03b26 100644 --- a/app/kubernetes/services/resourcePoolService.js +++ b/app/kubernetes/services/resourcePoolService.js @@ -36,8 +36,8 @@ export function KubernetesResourcePoolService( } // getting the quota for all namespaces is costly by default, so disable getting it by default - async function getAll({ getQuota = false }) { - const namespaces = await KubernetesNamespaceService.get(); + async function getAll({ getQuota = false, refreshCache = false }) { + const namespaces = await KubernetesNamespaceService.get('', refreshCache); const pools = await Promise.all( _.map(namespaces, async (namespace) => { const name = namespace.Name; diff --git a/app/kubernetes/views/applications/applicationsController.js b/app/kubernetes/views/applications/applicationsController.js index 028a7d927..ceeb83fe1 100644 --- a/app/kubernetes/views/applications/applicationsController.js +++ b/app/kubernetes/views/applications/applicationsController.js @@ -189,7 +189,7 @@ class KubernetesApplicationsController { }; this.state.namespaces = await this.KubernetesNamespaceService.get(); - this.state.namespaces = this.state.namespaces.filter((n) => n.Status !== 'Terminating'); + this.state.namespaces = this.state.namespaces.filter((n) => n.Status === 'Active'); this.state.namespaces = _.sortBy(this.state.namespaces, 'Name'); this.state.namespace = this.state.namespaces.length ? (this.state.namespaces.find((n) => n.Name === 'default') ? 'default' : this.state.namespaces[0].Name) : ''; diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js index cc069ef80..8fcff5d04 100644 --- a/app/kubernetes/views/applications/create/createApplicationController.js +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -1208,7 +1208,10 @@ class KubernetesCreateApplicationController { ]); this.nodesLimits = nodesLimits; - const nonSystemNamespaces = _.filter(resourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name)); + const nonSystemNamespaces = _.filter( + resourcePools, + (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active' + ); this.allNamespaces = resourcePools.map(({ Namespace }) => Namespace.Name); this.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1)); diff --git a/app/kubernetes/views/configurations/create/createConfigurationController.js b/app/kubernetes/views/configurations/create/createConfigurationController.js index 7be02d396..4b4ecc0f4 100644 --- a/app/kubernetes/views/configurations/create/createConfigurationController.js +++ b/app/kubernetes/views/configurations/create/createConfigurationController.js @@ -196,7 +196,10 @@ class KubernetesCreateConfigurationController { try { const resourcePools = await this.KubernetesResourcePoolService.get(); - this.resourcePools = _.filter(resourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name)); + this.resourcePools = _.filter( + resourcePools, + (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && resourcePool.Namespace.Status === 'Active' + ); this.formValues.ResourcePool = this.resourcePools[0]; await this.getConfigurations(); diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js index dc2248340..a3354dec4 100644 --- a/app/kubernetes/views/configure/configureController.js +++ b/app/kubernetes/views/configure/configureController.js @@ -165,7 +165,10 @@ class KubernetesConfigureController { const allResourcePools = await this.KubernetesResourcePoolService.get(); const resourcePools = _.filter( allResourcePools, - (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && !KubernetesNamespaceHelper.isDefaultNamespace(resourcePool.Namespace.Name) + (resourcePool) => + !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && + !KubernetesNamespaceHelper.isDefaultNamespace(resourcePool.Namespace.Name) && + resourcePool.Namespace.Status === 'Active' ); ingressesToDel.forEach((ingress) => { diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index aa66134f9..d10734791 100644 --- a/app/kubernetes/views/deploy/deployController.js +++ b/app/kubernetes/views/deploy/deployController.js @@ -284,7 +284,8 @@ class KubernetesDeployController { async getNamespacesAsync() { try { const pools = await this.KubernetesResourcePoolService.get(); - const namespaces = _.map(pools, 'Namespace').sort((a, b) => { + let namespaces = pools.filter((pool) => pool.Namespace.Status === 'Active'); + namespaces = _.map(namespaces, 'Namespace').sort((a, b) => { if (a.Name === 'default') { return -1; } diff --git a/app/kubernetes/views/resource-pools/resourcePoolsController.js b/app/kubernetes/views/resource-pools/resourcePoolsController.js index 55fd2a3ed..b487bb7c7 100644 --- a/app/kubernetes/views/resource-pools/resourcePoolsController.js +++ b/app/kubernetes/views/resource-pools/resourcePoolsController.js @@ -50,11 +50,11 @@ class KubernetesResourcePoolsController { } finally { --actionCount; if (actionCount === 0) { + await this.KubernetesNamespaceService.refreshCacheAsync(); this.$state.reload(this.$state.current); } } } - await this.KubernetesNamespaceService.refreshCacheAsync(); } removeAction(selectedItems) { @@ -77,7 +77,7 @@ class KubernetesResourcePoolsController { async getResourcePoolsAsync() { try { - this.resourcePools = await this.KubernetesResourcePoolService.get('', { getQuota: true }); + this.resourcePools = await this.KubernetesResourcePoolService.get('', { getQuota: true, refreshCache: true }); } catch (err) { this.Notifications.error('Failure', err, 'Unable to retreive namespaces'); } From f17a608dc7cc8ff968ea714b8bdebacbca1f98a8 Mon Sep 17 00:00:00 2001 From: Dakota Walsh <101994734+dakota-portainer@users.noreply.github.com> Date: Thu, 16 Mar 2023 15:39:26 +1300 Subject: [PATCH 43/55] fix(kubernetes): Prevent rerunning initial cluster detection [EE-5170] (#8666) --- .../test_data/output_24_to_latest.json | 1 + api/internal/endpointutils/endpointutils.go | 35 ++++++++++++++++--- api/portainer.go | 7 ++-- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index 6b0b59c70..9ef6f3d01 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -64,6 +64,7 @@ "UseServerMetrics": false }, "Flags": { + "IsServerIngressClassDetected": false, "IsServerMetricsDetected": false, "IsServerStorageDetected": false }, diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go index 163f785c7..918217ff3 100644 --- a/api/internal/endpointutils/endpointutils.go +++ b/api/internal/endpointutils/endpointutils.go @@ -76,6 +76,16 @@ func EndpointSet(endpointIDs []portainer.EndpointID) map[portainer.EndpointID]bo } func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) { + if endpoint.Kubernetes.Flags.IsServerIngressClassDetected { + return + } + defer func() { + endpoint.Kubernetes.Flags.IsServerIngressClassDetected = true + endpointService.UpdateEndpoint( + portainer.EndpointID(endpoint.ID), + endpoint, + ) + }() cli, err := factory.GetKubeClient(endpoint) if err != nil { log.Debug().Err(err).Msg("unable to create kubernetes client for ingress class detection") @@ -107,6 +117,16 @@ func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService } func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) { + if endpoint.Kubernetes.Flags.IsServerMetricsDetected { + return + } + defer func() { + endpoint.Kubernetes.Flags.IsServerMetricsDetected = true + endpointService.UpdateEndpoint( + portainer.EndpointID(endpoint.ID), + endpoint, + ) + }() cli, err := factory.GetKubeClient(endpoint) if err != nil { log.Debug().Err(err).Msg("unable to create kubernetes client for initial metrics detection") @@ -118,11 +138,6 @@ func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService datas return } endpoint.Kubernetes.Configuration.UseServerMetrics = true - endpoint.Kubernetes.Flags.IsServerMetricsDetected = true - err = endpointService.UpdateEndpoint( - portainer.EndpointID(endpoint.ID), - endpoint, - ) if err != nil { log.Debug().Err(err).Msg("unable to enable UseServerMetrics inside the database") return @@ -158,6 +173,16 @@ func storageDetect(endpoint *portainer.Endpoint, endpointService dataservices.En } func InitialStorageDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) { + if endpoint.Kubernetes.Flags.IsServerStorageDetected { + return + } + defer func() { + endpoint.Kubernetes.Flags.IsServerStorageDetected = true + endpointService.UpdateEndpoint( + portainer.EndpointID(endpoint.ID), + endpoint, + ) + }() log.Info().Msg("attempting to detect storage classes in the cluster") err := storageDetect(endpoint, endpointService, factory) if err == nil { diff --git a/api/portainer.go b/api/portainer.go index f4133047d..69eebcc0b 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -588,9 +588,12 @@ type ( Flags KubernetesFlags `json:"Flags"` } + // KubernetesFlags are used to detect if we need to run initial cluster + // detection again. KubernetesFlags struct { - IsServerMetricsDetected bool `json:"IsServerMetricsDetected"` - IsServerStorageDetected bool `json:"IsServerStorageDetected"` + IsServerMetricsDetected bool `json:"IsServerMetricsDetected"` + IsServerIngressClassDetected bool `json:"IsServerIngressClassDetected"` + IsServerStorageDetected bool `json:"IsServerStorageDetected"` } // KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time From 13ba72ee0744a9d4fc4d57a1e9e9c12d8f2db0c7 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Thu, 16 Mar 2023 18:50:54 +1300 Subject: [PATCH 44/55] fix(wizard): Capitalise Kubernetes [EE-5178] (#8662) Co-authored-by: testa113 --- .../wizard/EnvironmentTypeSelectView/environment-types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts index a289df49c..5e50b0494 100644 --- a/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts +++ b/app/react/portainer/environments/wizard/EnvironmentTypeSelectView/environment-types.ts @@ -46,7 +46,7 @@ export const existingEnvironmentTypes: EnvironmentOption[] = [ label: 'Kubernetes', icon: Kubernetes, iconType: 'logo', - description: 'Connect to a kubernetes environment via URL/IP', + description: 'Connect to a Kubernetes environment via URL/IP', }, { id: 'aci', From e142be399d88030e10ed6f874897af15ea20315e Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Fri, 17 Mar 2023 10:33:56 +1300 Subject: [PATCH 45/55] fix(kubeconfig): fix download checkbox [EE-5199] (#8674) Co-authored-by: testa113 --- .../EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx index 442405ad3..197960fc3 100644 --- a/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/KubeconfigButton/KubeconfigPrompt.tsx @@ -96,8 +96,10 @@ export function KubeconfigPrompt({ toggleSelection(env.Id, !selection[env.Id])} + checked={selection.includes(env.Id)} + onChange={() => + toggleSelection(env.Id, !selection.includes(env.Id)) + } />
))} From 76bdf6f2202f079f9f2c23518a604bd5b01cfcd4 Mon Sep 17 00:00:00 2001 From: andres-portainer <91705312+andres-portainer@users.noreply.github.com> Date: Fri, 17 Mar 2023 17:23:24 -0300 Subject: [PATCH 46/55] fix(websocket): use the read part of the buffer instead of everything EE-5235 (#8685) --- api/http/handler/websocket/stream.go | 36 ++++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/api/http/handler/websocket/stream.go b/api/http/handler/websocket/stream.go index c3e8a5835..77ce78566 100644 --- a/api/http/handler/websocket/stream.go +++ b/api/http/handler/websocket/stream.go @@ -31,14 +31,14 @@ func streamFromReaderToWebsocket(websocketConn *websocket.Conn, reader io.Reader out := make([]byte, readerBufferSize) for { - _, err := reader.Read(out) + n, err := reader.Read(out) if err != nil { errorChan <- err break } - processedOutput := validString(string(out)) + processedOutput := validString(string(out[:n])) err = websocketConn.WriteMessage(websocket.TextMessage, []byte(processedOutput)) if err != nil { errorChan <- err @@ -49,22 +49,22 @@ func streamFromReaderToWebsocket(websocketConn *websocket.Conn, reader io.Reader } func validString(s string) string { - if !utf8.ValidString(s) { - v := make([]rune, 0, len(s)) - - for i, r := range s { - if r == utf8.RuneError { - _, size := utf8.DecodeRuneInString(s[i:]) - if size == 1 { - continue - } - } - - v = append(v, r) - } - - s = string(v) + if utf8.ValidString(s) { + return s } - return s + v := make([]rune, 0, len(s)) + + for i, r := range s { + if r == utf8.RuneError { + _, size := utf8.DecodeRuneInString(s[i:]) + if size == 1 { + continue + } + } + + v = append(v, r) + } + + return string(v) } From 45def82156538a3e686a13b738198a510befb655 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Sun, 19 Mar 2023 13:37:35 +0100 Subject: [PATCH 47/55] fix(ui/box-selector): BE link and use icons standard size [EE-5133] (#8607) --- .../ldap/ldap-settings/ldap-settings.html | 1 + .../BoxSelector/BoxSelectorItem.tsx | 29 ++++++++++++------- .../BoxSelector/LimitedToBeIndicator.tsx | 18 ++++++------ .../common-options/deployment-methods.tsx | 2 ++ .../EdgeStackDeploymentTypeSelector.tsx | 1 + .../InternalAuth/options.tsx | 8 +++-- .../AuthenticationView/ldap-options.tsx | 6 ++-- .../AuthenticationView/oauth-options.tsx | 8 +++-- 8 files changed, 44 insertions(+), 29 deletions(-) diff --git a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html index 95416ac92..d6b889385 100644 --- a/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html +++ b/app/portainer/settings/authentication/ldap/ldap-settings/ldap-settings.html @@ -14,6 +14,7 @@ value="$ctrl.settings.ServerType" options="$ctrl.boxSelectorOptions" on-change="($ctrl.onChangeServerType)" + slim="true" > ({ slim = false, checkIcon = Check, }: Props) { - const limitedToBE = isLimitedToBE(option.feature); + const { limitedToBE = false, url: featureUrl } = getFeatureDetails( + option.feature + ); const beIndicatorTooltipId = `box-selector-item-${radioName}-${option.id}-limited`; @@ -57,7 +59,12 @@ export function BoxSelectorItem({ type={type} checkIcon={checkIcon} > - {limitedToBE && } + {limitedToBE && ( + + )}
({ return ; } - if (option.iconType === 'logo') { - return ; + if (option.iconType === 'raw') { + return ( + + ); } - return ( - - ); + return ; } } diff --git a/app/react/components/BoxSelector/LimitedToBeIndicator.tsx b/app/react/components/BoxSelector/LimitedToBeIndicator.tsx index f0b850272..ad992a5e7 100644 --- a/app/react/components/BoxSelector/LimitedToBeIndicator.tsx +++ b/app/react/components/BoxSelector/LimitedToBeIndicator.tsx @@ -1,24 +1,24 @@ import { HelpCircle } from 'lucide-react'; import clsx from 'clsx'; -import { FeatureId } from '@/react/portainer/feature-flags/enums'; - import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; -import { getFeatureDetails } from '@@/BEFeatureIndicator/utils'; interface Props { tooltipId: string; - featureId?: FeatureId; + url?: string; } -export function LimitedToBeIndicator({ featureId, tooltipId }: Props) { - const { url } = getFeatureDetails(featureId); - +export function LimitedToBeIndicator({ tooltipId, url }: Props) { return (
- - BE Feature + + BE Feature = { label: 'Kubernetes', description: 'Kubernetes manifest format', value: 'kubernetes', + iconType: 'logo', }; export const compose: BoxSelectorOption<'compose'> = { @@ -17,4 +18,5 @@ export const compose: BoxSelectorOption<'compose'> = { label: 'Compose', description: 'docker-compose format', value: 'compose', + iconType: 'logo', }; diff --git a/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx b/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx index 1ca5f83c7..85a2423fe 100644 --- a/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx +++ b/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx @@ -40,6 +40,7 @@ export function EdgeStackDeploymentTypeSelector({ hasDockerEndpoint ? 'Cannot use this option with Edge Docker environments' : '', + iconType: 'logo', }, ]; diff --git a/app/react/portainer/settings/AuthenticationView/InternalAuth/options.tsx b/app/react/portainer/settings/AuthenticationView/InternalAuth/options.tsx index 17cd50404..0155d68f2 100644 --- a/app/react/portainer/settings/AuthenticationView/InternalAuth/options.tsx +++ b/app/react/portainer/settings/AuthenticationView/InternalAuth/options.tsx @@ -5,12 +5,11 @@ import Microsoft from '@/assets/ico/vendor/microsoft.svg?c'; import Ldap from '@/assets/ico/ldap.svg?c'; import OAuth from '@/assets/ico/oauth.svg?c'; -import { BadgeIcon } from '@@/BadgeIcon'; - export const options = [ { id: 'auth_internal', - icon: , + icon: ArrowDownCircle, + iconType: 'badge', label: 'Internal', description: 'Internal authentication mechanism', value: 1, @@ -20,6 +19,7 @@ export const options = [ icon: Ldap, label: 'LDAP', description: 'LDAP authentication', + iconType: 'logo', value: 2, }, { @@ -27,6 +27,7 @@ export const options = [ icon: Microsoft, label: 'Microsoft Active Directory', description: 'AD authentication', + iconType: 'logo', value: 4, feature: FeatureId.HIDE_INTERNAL_AUTH, }, @@ -35,6 +36,7 @@ export const options = [ icon: OAuth, label: 'OAuth', description: 'OAuth authentication', + iconType: 'logo', value: 3, }, ]; diff --git a/app/react/portainer/settings/AuthenticationView/ldap-options.tsx b/app/react/portainer/settings/AuthenticationView/ldap-options.tsx index d76fab961..e46fecda0 100644 --- a/app/react/portainer/settings/AuthenticationView/ldap-options.tsx +++ b/app/react/portainer/settings/AuthenticationView/ldap-options.tsx @@ -3,8 +3,6 @@ import { Edit } from 'lucide-react'; import { FeatureId } from '@/react/portainer/feature-flags/enums'; import Openldap from '@/assets/ico/vendor/openldap.svg?c'; -import { BadgeIcon } from '@@/BadgeIcon'; - const SERVER_TYPES = { CUSTOM: 0, OPEN_LDAP: 1, @@ -14,7 +12,8 @@ const SERVER_TYPES = { export const options = [ { id: 'ldap_custom', - icon: , + icon: Edit, + iconType: 'badge', label: 'Custom', value: SERVER_TYPES.CUSTOM, }, @@ -24,5 +23,6 @@ export const options = [ label: 'OpenLDAP', value: SERVER_TYPES.OPEN_LDAP, feature: FeatureId.EXTERNAL_AUTH_LDAP, + iconType: 'logo', }, ]; diff --git a/app/react/portainer/settings/AuthenticationView/oauth-options.tsx b/app/react/portainer/settings/AuthenticationView/oauth-options.tsx index e1fb43090..f9d3c4c0c 100644 --- a/app/react/portainer/settings/AuthenticationView/oauth-options.tsx +++ b/app/react/portainer/settings/AuthenticationView/oauth-options.tsx @@ -5,8 +5,6 @@ import Microsoft from '@/assets/ico/vendor/microsoft.svg?c'; import Google from '@/assets/ico/vendor/google.svg?c'; import Github from '@/assets/ico/vendor/github.svg?c'; -import { BadgeIcon } from '@@/BadgeIcon'; - export const options = [ { id: 'microsoft', @@ -14,6 +12,7 @@ export const options = [ label: 'Microsoft', description: 'Microsoft OAuth provider', value: 'microsoft', + iconType: 'logo', feature: FeatureId.HIDE_INTERNAL_AUTH, }, { @@ -22,6 +21,7 @@ export const options = [ label: 'Google', description: 'Google OAuth provider', value: 'google', + iconType: 'logo', feature: FeatureId.HIDE_INTERNAL_AUTH, }, { @@ -30,11 +30,13 @@ export const options = [ label: 'Github', description: 'Github OAuth provider', value: 'github', + iconType: 'logo', feature: FeatureId.HIDE_INTERNAL_AUTH, }, { id: 'custom', - icon: , + icon: Edit, + iconType: 'badge', label: 'Custom', description: 'Custom OAuth provider', value: 'custom', From 124e0bf9b96e158400e06f466fc5dc1cbfd3228a Mon Sep 17 00:00:00 2001 From: Oscar Zhou <100548325+oscarzhou-portainer@users.noreply.github.com> Date: Mon, 20 Mar 2023 10:00:49 +1300 Subject: [PATCH 48/55] fix(stack/git): unexpected cursor movement in git text fields [EE-5143] (#8655) --- .../stack-redeploy-git-form.controller.js | 2 +- app/react/hooks/useStateWrapper.ts | 19 +++++++++++++++++++ .../portainer/gitops/AdditionalFilesField.tsx | 11 +++++------ .../ComposePathField/ComposePathField.tsx | 11 +++++------ .../gitops/ComposePathField/PathSelector.tsx | 11 ++--------- .../portainer/gitops/RefField/RefField.tsx | 7 +++++-- 6 files changed, 37 insertions(+), 24 deletions(-) create mode 100644 app/react/hooks/useStateWrapper.ts diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js index c53e96e76..262d278e8 100644 --- a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js @@ -182,7 +182,7 @@ class StackRedeployGitFormController { this.formValues.AutoUpdate = parseAutoUpdateResponse(this.stack.AutoUpdate); - if (this.stack.AutoUpdate.Webhook) { + if (this.stack.AutoUpdate && this.stack.AutoUpdate.Webhook) { this.state.webhookId = this.stack.AutoUpdate.Webhook; } diff --git a/app/react/hooks/useStateWrapper.ts b/app/react/hooks/useStateWrapper.ts new file mode 100644 index 000000000..f0bbc3d53 --- /dev/null +++ b/app/react/hooks/useStateWrapper.ts @@ -0,0 +1,19 @@ +import { useState, useCallback, useEffect } from 'react'; + +export function useStateWrapper(value: T, onChange: (value: T) => void) { + const [inputValue, setInputValue] = useState(value); + + const updateInputValue = useCallback( + (value: T) => { + setInputValue(value); + onChange(value); + }, + [onChange, setInputValue] + ); + + useEffect(() => { + setInputValue(value); + }, [value]); + + return [inputValue, updateInputValue] as const; +} diff --git a/app/react/portainer/gitops/AdditionalFilesField.tsx b/app/react/portainer/gitops/AdditionalFilesField.tsx index 47d506fbb..552d7adc4 100644 --- a/app/react/portainer/gitops/AdditionalFilesField.tsx +++ b/app/react/portainer/gitops/AdditionalFilesField.tsx @@ -1,9 +1,10 @@ import { FormikErrors } from 'formik'; +import { useStateWrapper } from '@/react/hooks/useStateWrapper'; + import { FormError } from '@@/form-components/FormError'; import { InputGroup } from '@@/form-components/InputGroup'; import { InputList, ItemProps } from '@@/form-components/InputList'; -import { useCaretPosition } from '@@/form-components/useCaretPosition'; interface Props { value: Array; @@ -32,21 +33,19 @@ function Item({ error, readOnly, }: ItemProps) { - const { ref, updateCaret } = useCaretPosition(); + const [inputValue, updateInputValue] = useStateWrapper(item, onChange); return (
path { - onChange(e.target.value); - updateCaret(); + updateInputValue(e.target.value); }} /> diff --git a/app/react/portainer/gitops/ComposePathField/ComposePathField.tsx b/app/react/portainer/gitops/ComposePathField/ComposePathField.tsx index f8a4b3e19..c8b1bd06b 100644 --- a/app/react/portainer/gitops/ComposePathField/ComposePathField.tsx +++ b/app/react/portainer/gitops/ComposePathField/ComposePathField.tsx @@ -1,7 +1,8 @@ +import { useStateWrapper } from '@/react/hooks/useStateWrapper'; + import { FormControl } from '@@/form-components/FormControl'; import { TextTip } from '@@/Tip/TextTip'; import { Input } from '@@/form-components/Input'; -import { useCaretPosition } from '@@/form-components/useCaretPosition'; import { GitFormModel } from '../types'; import { isBE } from '../../feature-flags/feature-flags.service'; @@ -25,7 +26,7 @@ export function ComposePathField({ isDockerStandalone, errors, }: Props) { - const { ref, updateCaret } = useCaretPosition(); + const [inputValue, updateInputValue] = useStateWrapper(value, onChange); return (
@@ -65,11 +66,9 @@ export function ComposePathField({ /> ) : ( { - onChange(e.target.value); - updateCaret(); + updateInputValue(e.target.value); }} placeholder={isCompose ? 'docker-compose.yml' : 'manifest.yml'} /> diff --git a/app/react/portainer/gitops/ComposePathField/PathSelector.tsx b/app/react/portainer/gitops/ComposePathField/PathSelector.tsx index 935edcd87..9f4e41144 100644 --- a/app/react/portainer/gitops/ComposePathField/PathSelector.tsx +++ b/app/react/portainer/gitops/ComposePathField/PathSelector.tsx @@ -12,8 +12,6 @@ import clsx from 'clsx'; import { useSearch } from '@/react/portainer/gitops/queries/useSearch'; import { useDebounce } from '@/react/hooks/useDebounce'; -import { useCaretPosition } from '@@/form-components/useCaretPosition'; - import { getAuthentication } from '../utils'; import { GitFormModel } from '../types'; @@ -30,7 +28,7 @@ export function PathSelector({ placeholder: string; model: GitFormModel; }) { - const [searchTerm, setSearchTerm] = useDebounce('', () => {}); + const [searchTerm, setSearchTerm] = useDebounce(value, onChange); const creds = getAuthentication(model); const payload = { @@ -43,7 +41,6 @@ export function PathSelector({ model.RepositoryURL && model.RepositoryURLValid && searchTerm ); const { data: searchResults } = useSearch(payload, enabled); - const { ref, updateCaret } = useCaretPosition(); return ( {searchResults && searchResults.length > 0 && ( @@ -81,12 +77,9 @@ export function PathSelector({ function handleChange(e: ChangeEvent) { setSearchTerm(e.target.value); - onChange(e.target.value); - updateCaret(); } function onSelect(value: string) { - setSearchTerm(''); onChange(value); } } diff --git a/app/react/portainer/gitops/RefField/RefField.tsx b/app/react/portainer/gitops/RefField/RefField.tsx index 896099002..fb65af145 100644 --- a/app/react/portainer/gitops/RefField/RefField.tsx +++ b/app/react/portainer/gitops/RefField/RefField.tsx @@ -2,6 +2,7 @@ import { PropsWithChildren, ReactNode } from 'react'; import { SchemaOf, string } from 'yup'; import { StackId } from '@/react/docker/stacks/types'; +import { useStateWrapper } from '@/react/hooks/useStateWrapper'; import { FormControl } from '@@/form-components/FormControl'; import { Input } from '@@/form-components/Input'; @@ -29,6 +30,8 @@ export function RefField({ isUrlValid, stackId, }: Props) { + const [inputValue, updateInputValue] = useStateWrapper(value, onChange); + return isBE ? ( onChange(e.target.value)} + value={inputValue} + onChange={(e) => updateInputValue(e.target.value)} placeholder="refs/heads/main" /> From f6e8b25cf3b3906feb97de04905b77eb0a88655b Mon Sep 17 00:00:00 2001 From: Prabhat Khera <91852476+prabhat-org@users.noreply.github.com> Date: Tue, 21 Mar 2023 16:06:01 +1300 Subject: [PATCH 49/55] fix Gpus null issue (#8692) --- .../docker-features-configuration.controller.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js index a6251013a..b6270ee57 100644 --- a/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.controller.js @@ -179,6 +179,8 @@ export default class DockerFeaturesConfigurationController { disableSysctlSettingForRegularUsers: !securitySettings.allowSysctlSettingForRegularUsers, }; + // this.endpoint.Gpus could be null as it is Gpus: []Pair in the API + this.endpoint.Gpus = this.endpoint.Gpus || []; this.state.enableGPUManagement = this.isDockerStandaloneEnv && (this.endpoint.EnableGPUManagement || this.endpoint.Gpus.length > 0); this.initialGPUs = this.endpoint.Gpus; this.initialEnableGPUManagement = this.endpoint.EnableGPUManagement; From 3636ac5c263db33ce1b23e37ab006ddae28d5671 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Wed, 22 Mar 2023 15:34:44 +1300 Subject: [PATCH 50/55] fix(dashboard): use faster proxy request [EE-5160] (#8693) Co-authored-by: testa113 --- app/kubernetes/__module.js | 9 ++++++++- app/react/kubernetes/namespaces/service.ts | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index 5ebe986fb..470404b8e 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -1,4 +1,5 @@ import { EnvironmentStatus } from '@/react/portainer/environments/types'; +import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/service'; import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; @@ -47,7 +48,13 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo throw new Error('Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.'); } - await KubernetesNamespaceService.get(); + // use selfsubject access review to check if we can connect to the kubernetes environment + // because it's gets a fast response, and is accessible to all users + try { + await getSelfSubjectAccessReview(endpoint.Id, 'default'); + } catch (e) { + throw new Error('Environment is unreachable.'); + } } catch (e) { let params = {}; diff --git a/app/react/kubernetes/namespaces/service.ts b/app/react/kubernetes/namespaces/service.ts index 27549c2f3..405eed82a 100644 --- a/app/react/kubernetes/namespaces/service.ts +++ b/app/react/kubernetes/namespaces/service.ts @@ -3,6 +3,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import { Namespaces, SelfSubjectAccessReviewResponse } from './types'; +// getNamespace is used to retrieve a namespace using the Portainer backend export async function getNamespace( environmentId: EnvironmentId, namespace: string @@ -17,6 +18,7 @@ export async function getNamespace( } } +// getNamespaces is used to retrieve namespaces using the Portainer backend with caching export async function getNamespaces(environmentId: EnvironmentId) { try { const { data: namespaces } = await axios.get( From 30248eabb42810ef1c88bd3ca5712065f05ef2a7 Mon Sep 17 00:00:00 2001 From: Ali <83188384+testA113@users.noreply.github.com> Date: Thu, 23 Mar 2023 08:20:30 +1300 Subject: [PATCH 51/55] fix(apps) UI release fixes [EE-5197] (#8702) * fix(apps) searchbar flex resizing and insights * UI fixes * update stacks datatable --------- Co-authored-by: testa113 --- app/docker/react/components/index.ts | 12 + .../applicationsDatatable.html | 230 +++++++++--------- .../applicationsStacksDatatable.html | 189 +++++++------- .../components/datatables/datatable.css | 7 +- .../components/InsightsBox/InsightsBox.tsx | 56 +++-- 5 files changed, 277 insertions(+), 217 deletions(-) diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts index a34888528..fb815b58c 100644 --- a/app/docker/react/components/index.ts +++ b/app/docker/react/components/index.ts @@ -14,6 +14,7 @@ import { DockerfileDetails } from '@/react/docker/images/ItemView/DockerfileDeta import { HealthStatus } from '@/react/docker/containers/ItemView/HealthStatus'; import { GpusList } from '@/react/docker/host/SetupView/GpusList'; import { GpusInsights } from '@/react/docker/host/SetupView/GpusInsights'; +import { InsightsBox } from '@/react/components/InsightsBox'; export const componentsModule = angular .module('portainer.docker.react.components', []) @@ -53,4 +54,15 @@ export const componentsModule = angular 'gpusList', r2a(withControlledInput(GpusList), ['value', 'onChange']) ) + .component( + 'insightsBox', + r2a(InsightsBox, [ + 'header', + 'content', + 'setHtmlContent', + 'insightCloseId', + 'type', + 'className', + ]) + ) .component('gpusInsights', r2a(GpusInsights, [])).name; diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html index 25fe51b06..0f4c6935b 100644 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html +++ b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html @@ -1,132 +1,144 @@
-
-
+
+
Applications
-
-
- - - Namespace - - -
-
- -
- - - -
-
- - - - -