From 07fa578b58eecb50368e61d6b6e613c74e099e83 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Wed, 27 May 2026 11:34:16 +0200 Subject: [PATCH 01/20] chore(gui): add docs reference for phased deployments Signed-off-by: Manuel Zedel --- frontend/src/js/common-ui/DocsLink.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/js/common-ui/DocsLink.tsx b/frontend/src/js/common-ui/DocsLink.tsx index 45c430f6a..3a2e9a537 100644 --- a/frontend/src/js/common-ui/DocsLink.tsx +++ b/frontend/src/js/common-ui/DocsLink.tsx @@ -63,6 +63,7 @@ const useStyles = makeStyles()(theme => ({ export const DOCSTIPS = { deviceConfig: { id: 'deviceConfig', path: 'add-ons/configure' }, deviceIdentity: { id: 'deviceIdentity', path: 'client-installation/identity' }, + dynamicDeployments: { id: 'dynamicDeployments', path: 'overview/deployment#phased-rollouts-and-dynamic-groups' }, dynamicGroups: { id: 'dynamicGroups', path: 'overview/device-group#dynamic-group' }, hostedRegions: { id: 'hostedRegions', path: 'general/hosted-mender-regions' }, limitedDeployments: { id: 'limitedDeployments', path: 'overview/deployment#deployment-to-dynamic-groups' }, From 3f2e3a006ad8b21c8ca9abdfe99f95df1b8bad39 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Wed, 27 May 2026 11:34:23 +0200 Subject: [PATCH 02/20] chore(gui): added shared deployment phase types + modes information & utils Signed-off-by: Manuel Zedel --- .../deployment-wizard/phases/constants.ts | 58 +++ .../deployment-wizard/phases/utils.tsx | 336 ++++++++++++++++++ .../deployments/deployment-wizard/types.ts | 12 +- .../deployments/deployment-wizard/utils.ts | 16 + 4 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 frontend/src/js/components/deployments/deployment-wizard/phases/constants.ts create mode 100644 frontend/src/js/components/deployments/deployment-wizard/phases/utils.tsx diff --git a/frontend/src/js/components/deployments/deployment-wizard/phases/constants.ts b/frontend/src/js/components/deployments/deployment-wizard/phases/constants.ts new file mode 100644 index 000000000..f9bbf4878 --- /dev/null +++ b/frontend/src/js/components/deployments/deployment-wizard/phases/constants.ts @@ -0,0 +1,58 @@ +// Copyright 2026 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const rolloutModes = { + percentage: { key: 'percentage', title: 'By percentage of total devices', batchKey: 'batch_size' }, + device_count: { key: 'device_count', title: 'By number of devices', batchKey: 'batch_size_devices' } +}; + +export type RolloutMode = keyof typeof rolloutModes; + +export const rolloutPatterns = { + custom: { + key: 'custom', + title: 'Custom', + tip: 'Define each deployment phase individually' + }, + uniform: { + key: 'uniform', + title: 'Uniform (repeat until all devices deployed)', + tip: 'Repeat all phases until all devices are deployed' + } +}; + +export type RolloutPattern = keyof typeof rolloutPatterns; + +export const delayUnits = { + minutes: 'minutes', + hours: 'hours', + days: 'days' +}; + +export const phaseDefaults = { + batchSize: 10, + delay: 7200 +}; + +export const delayDefaults = { + delay: 2, + delayUnit: delayUnits.hours +}; + +export const phaseLimits = { + maxPerBatchPercentage: 99, + fullBatchPercentage: 100, + maxDefaultBatchDevices: 2000, + fallbackDeviceCount: 100 +}; diff --git a/frontend/src/js/components/deployments/deployment-wizard/phases/utils.tsx b/frontend/src/js/components/deployments/deployment-wizard/phases/utils.tsx new file mode 100644 index 000000000..65dd2d92f --- /dev/null +++ b/frontend/src/js/components/deployments/deployment-wizard/phases/utils.tsx @@ -0,0 +1,336 @@ +// Copyright 2026 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import type { ReactNode } from 'react'; + +import type { AlertProps } from '@mui/material'; +import { alpha } from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; + +import { DOCSTIPS, DocsTextLink } from '@northern.tech/common-ui/DocsLink'; +import type { NewDeploymentPhaseTypeManagement as DeploymentPhase, Filter } from '@northern.tech/types/MenderTypes'; +import pluralize from 'pluralize'; + +import type { RolloutMode, RolloutPattern } from './constants'; +import { delayDefaults, phaseDefaults, phaseLimits, rolloutModes, rolloutPatterns } from './constants'; + +export const useRowStyles = makeStyles()(theme => ({ + rowError: { backgroundColor: alpha(theme.palette.error.main, theme.palette.action.selectedOpacity) }, + rowWarning: { backgroundColor: alpha(theme.palette.warning.main, theme.palette.action.selectedOpacity) } +})); + +const getRemainderPercent = (phases: DeploymentPhase[]) => { + const percentage = phases.reduce((accu, phase, index, source) => { + if (index === source.length - 1) { + return accu; + } + return phase.batch_size ? accu - phase.batch_size : accu; + }, 100); + return Math.max(0, percentage); +}; + +const getRemainderDevices = (phases: DeploymentPhase[], numberDevices: number): number => { + const count = + numberDevices - + phases.reduce((accu, phase, index, source) => { + if (index === source.length - 1) { + return accu; + } + return accu + (phase.batch_size_devices || 0); + }, 0); + return Math.max(0, count); +}; + +export const getRemainder = ({ + phases, + numberDevices, + rolloutMode +}: { + numberDevices: number; + phases: DeploymentPhase[]; + rolloutMode: RolloutMode; +}): number => { + if (rolloutMode === rolloutModes.percentage.key) { + return getRemainderPercent(phases); + } + return getRemainderDevices(phases, numberDevices); +}; + +export const getPhaseDeviceCount = (numberDevices = 1, batchSize: number, remainder: number, isLastPhase: boolean) => { + const count = (numberDevices / 100) * (batchSize || remainder); + return isLastPhase ? Math.ceil(count) : Math.floor(count); +}; + +export const percentageToDevices = (percentage: number, numberDevices: number): number => + numberDevices > 0 ? Math.max(1, Math.floor((numberDevices / 100) * percentage)) : 0; + +export const devicesToPercentage = (devices: number, numberDevices: number): number => + numberDevices > 0 ? Math.max(1, Math.min(phaseLimits.maxPerBatchPercentage, Math.round((devices / numberDevices) * 100))) : phaseDefaults.batchSize; + +export type PhaseMessage = { + message: string | ReactNode; + severity: AlertProps['severity']; +}; + +interface GetPhaseMessagesBaseProps { + deploymentDeviceCount: number; + isLast: boolean; + maxDevices?: number; + phase: DeploymentPhase; + remainder: number; +} + +const getPercentagePhaseMessages = ({ phase, isLast, remainder, deploymentDeviceCount }: GetPhaseMessagesBaseProps): PhaseMessage[] => { + const messages: PhaseMessage[] = []; + const { batch_size: batchSize } = phase; + if (batchSize != null && !isLast && (batchSize < 1 || batchSize > phaseLimits.maxPerBatchPercentage)) { + messages.push({ message: 'Please enter a value between 1% and 99%', severity: 'error' }); + } + const effectiveSize = batchSize || remainder; + if (effectiveSize > 0 && Math.floor((deploymentDeviceCount / 100) * effectiveSize) < 1) { + messages.push({ message: `${effectiveSize}% rounds down to 0 devices. Increase the percentage or switch to device count mode.`, severity: 'error' }); + } + if (!effectiveSize) { + messages.push({ message: 'Phases must have at least 1 device', severity: 'error' }); + } + return messages; +}; + +const getDeviceCountPhaseMessages = ({ + phase, + isDynamic = false, + isLast, + remainder, + deploymentDeviceCount, + phasesLength, + maxDevices +}: GetPhaseMessagesBaseProps & { + isDynamic: boolean; + phasesLength: number; +}): PhaseMessage[] => { + const messages: PhaseMessage[] = []; + const { batch_size_devices: batchDevices } = phase; + if (batchDevices > deploymentDeviceCount) { + if (isDynamic) { + messages.push({ + message: `Rollout size exceeds the current target group size. Any new devices added to the group will join this phase until it's full`, + severity: 'warning' + }); + } else { + messages.push({ message: 'Rollout size exceeds total target group size', severity: 'error' }); + } + } + + if (!isLast && batchDevices === 0) { + messages.push({ message: 'Phases must have at least 1 device', severity: 'error' }); + } + if (isLast && remainder < 1 && phasesLength) { + messages.push({ message: 'Phases must have at least 1 device', severity: 'error' }); + } + if (maxDevices && batchDevices !== null && batchDevices > maxDevices && !isDynamic) { + messages.push({ message: 'Rollout size cannot exceed the maximum number devices', severity: 'error' }); + } + return messages; +}; + +export const getPhaseMessages = ({ + isDynamic, + phases, + phaseIndex, + deploymentDeviceCount, + rolloutMode, + maxDevices +}: { + deploymentDeviceCount: number; + isDynamic: boolean; + maxDevices?: number; + phaseIndex: number; + phases: DeploymentPhase[]; + rolloutMode: RolloutMode; +}): PhaseMessage[] => { + if (!phases?.length) { + return []; + } + const isPercentage = rolloutMode === rolloutModes.percentage.key; + const remainder = isPercentage ? getRemainderPercent(phases) : getRemainderDevices(phases, deploymentDeviceCount); + + const isLast = phaseIndex === phases.length - 1; + const phaseMessages = isPercentage + ? getPercentagePhaseMessages({ phase: phases[phaseIndex], isLast, remainder, deploymentDeviceCount }) + : getDeviceCountPhaseMessages({ phase: phases[phaseIndex], isDynamic, isLast, remainder, deploymentDeviceCount, phasesLength: phases.length, maxDevices }); + return phaseMessages; +}; + +export const getPhasesMessage = ({ + filter, + rolloutPattern, + maxDevices +}: { + filter?: Filter; + maxDevices: number; + rolloutPattern: RolloutPattern; +}): PhaseMessage | undefined => { + if (!filter) { + return; + } + if (rolloutPattern === rolloutPatterns.uniform.key && !maxDevices) { + return { + message: 'This deployment targets a dynamic device group using a uniform rollout. The deployment remains active until you manually stop it.', + severity: 'info' + }; + } + if (rolloutPattern !== rolloutPatterns.uniform.key && maxDevices) { + return { message: `This deployment will stop at ${maxDevices} ${pluralize('device', maxDevices)} due to the device limit above`, severity: 'info' }; + } + return { + message: ( + <> + This deployment targets a dynamic device group, so the final phase may adjust as devices change. The last phase stays active to keep all devices + updated. + + ), + severity: 'info' + }; +}; + +const deviceCountThresholds = { + million: 1_000_000, + tenThousand: 10_000, + oneThousand: 1_000 +}; + +const toFixedWithoutRounding = (number: number) => (Math.trunc(number * 10) / 10).toFixed(1).replace(/\.0$/, ''); + +export const formatDeviceCount = (count: number): string => { + if (!Number.isFinite(count) || count < 0) return '0'; + if (count >= deviceCountThresholds.million) { + const number = count / deviceCountThresholds.million; + return number < 10 ? `${toFixedWithoutRounding(number)}M` : `${Math.floor(number)}M`; + } + if (count >= deviceCountThresholds.tenThousand) return `${Math.floor(count / deviceCountThresholds.oneThousand)}K`; + if (count >= deviceCountThresholds.oneThousand) { + const number = count / deviceCountThresholds.oneThousand; + return `${toFixedWithoutRounding(number)}K`; + } + return count.toLocaleString(); +}; + +export interface StandardizedPhase { + batch_size?: number; + batch_size_devices?: number; + delay?: number; + delayUnit?: string; + device_count?: number; + isUniform?: boolean; + start_ts?: number; +} + +export type UiDeploymentPhase = DeploymentPhase & StandardizedPhase; + +type ReadablePhaseDescriptions = { phasesDescription: string; tooltip: string }; + +const toUniformPhasesDescription = (phases: StandardizedPhase[], numberDevices: number): ReadablePhaseDescriptions => { + const isPercentageMode = phases.some(phase => phase.hasOwnProperty(rolloutModes.percentage.batchKey)); + const { delay, delayUnit, batch_size, batch_size_devices } = phases[0]; + const prefix = 'Uniform: '; + let phasesDescription = ''; + if (isPercentageMode) { + phasesDescription = `${batch_size}% per phase, ${delay}${delayUnit || delayDefaults.delayUnit} intervals`; + return { phasesDescription: `${prefix}${phasesDescription}`, tooltip: phasesDescription }; + } + phasesDescription = `${Math.min(numberDevices, batch_size_devices!)} devices per phase, ${delay}${delayUnit || delayDefaults.delayUnit} intervals`; + return { phasesDescription: `${prefix}${phasesDescription}`, tooltip: phasesDescription }; +}; + +export const toPhaseDescription = (phases: StandardizedPhase[], numberDevices: number): ReadablePhaseDescriptions => { + const isPercentageMode = phases.some(phase => phase.hasOwnProperty(rolloutModes.percentage.batchKey)); + const { isUniform } = phases.length ? phases[0] : {}; + if (isUniform) { + return toUniformPhasesDescription(phases, numberDevices); + } + const prefix = `${phases.length} ${pluralize('phase', phases.length)}: `; + if (isPercentageMode) { + const remainder = getRemainderPercent(phases); + const phasesDescription = phases.map((phase, _, source) => `${phase.batch_size || remainder || 100 / source.length}%`).join(', '); + const tooltip = phases + .map(({ delay, delayUnit, batch_size }, _, source) => + delay ? `${batch_size}% > ${delay} ${delayUnit || delayDefaults.delayUnit} >` : `${batch_size || remainder || 100 / source.length}%` + ) + .join(', '); + return { phasesDescription: `${prefix}${phasesDescription}`, tooltip }; + } + const remainder = getRemainderDevices(phases, numberDevices); + const phasesDescription = phases.map(phase => phase.batch_size_devices || remainder).join(', '); + const tooltip = phases + .map(({ delay, delayUnit, batch_size_devices }) => + delay ? `${batch_size_devices} > ${delay} ${delayUnit || delayDefaults.delayUnit} >` : batch_size_devices || remainder + ) + .join(', '); + return { phasesDescription: `${prefix}${phasesDescription}`, tooltip }; +}; + +interface PhaseInfoProps { + index: number; + isDynamic: boolean; + maxDevices: number; + numberDevices: number; + phases: Array; + rolloutMode: RolloutMode; +} + +type PhaseInfo = { + batchValue?: number; + deviceCount: number; + hasError: boolean; + hasWarning: boolean; + max: number; + messages: PhaseMessage[]; +}; + +export const computePhaseInfo = ({ index, phases, isDynamic, numberDevices, rolloutMode, maxDevices }: PhaseInfoProps): PhaseInfo => { + const phase = phases[index]; + const isLast = index === phases.length - 1; + const isPercentageMode = rolloutMode === rolloutModes.percentage.key; + const messages = getPhaseMessages({ isDynamic, phases, phaseIndex: index, deploymentDeviceCount: numberDevices, rolloutMode, maxDevices }); + + const { hasError, hasWarning } = messages.reduce( + (accu, { severity }) => ({ hasError: accu.hasError || severity === 'error', hasWarning: accu.hasWarning || severity === 'warning' }), + { hasError: false, hasWarning: false } + ); + + const remainder = getRemainder({ phases, numberDevices, rolloutMode }); + + if (isPercentageMode) { + const batchValue = Math.max(0, isLast ? remainder : phase.batch_size); + const deviceCount = getPhaseDeviceCount(numberDevices, phase.batch_size, remainder, isLast); + return { + batchValue, + deviceCount, + hasError, + hasWarning, + max: remainder, + messages + }; + } + const batchValue = isLast ? remainder : phase.batch_size_devices; + const max = numberDevices > 0 ? numberDevices : Number.MAX_SAFE_INTEGER; + const deviceCount = batchValue || (isLast ? remainder : 0); + return { + batchValue, + deviceCount, + hasError, + hasWarning, + max, + messages + }; +}; diff --git a/frontend/src/js/components/deployments/deployment-wizard/types.ts b/frontend/src/js/components/deployments/deployment-wizard/types.ts index aa3052930..3a7610213 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/types.ts +++ b/frontend/src/js/components/deployments/deployment-wizard/types.ts @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. import type { Release } from '@northern.tech/store/releasesSlice'; -import type { Device, Filter, NewDeploymentPhaseTypeManagement, NewDeploymentTypeManagement } from '@northern.tech/types/MenderTypes'; +import type { DeploymentUniformPhase, Device, Filter, NewDeploymentPhaseTypeManagement, NewDeploymentTypeManagement } from '@northern.tech/types/MenderTypes'; + +import type { RolloutMode } from './phases/constants'; export type DeploymentSettings = Partial<{ delta: boolean; @@ -26,10 +28,16 @@ export type DeploymentSettings = Partial<{ phases: Array; release: Release; retries: number; + rolloutMode: RolloutMode; + uniform_phases: DeploymentUniformPhase; update_control_map: NewDeploymentTypeManagement['update_control_map']; }>; -export type DeploymentFormValues = Pick & { +export type DeploymentFormValues = Pick< + DeploymentSettings, + 'delta' | 'forceDeploy' | 'maxDevices' | 'retries' | 'phases' | 'update_control_map' | 'rolloutMode' | 'uniform_phases' +> & { group: string | null; release: Release | null; + startTime?: string; }; diff --git a/frontend/src/js/components/deployments/deployment-wizard/utils.ts b/frontend/src/js/components/deployments/deployment-wizard/utils.ts index ea71ed7ef..0462241aa 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/utils.ts +++ b/frontend/src/js/components/deployments/deployment-wizard/utils.ts @@ -19,6 +19,8 @@ import { getDeviceCountsByStatus, getDevicesById, getGroupData } from '@northern import { useAppDispatch, useAppSelector } from '@northern.tech/store/store'; import { getGroupDevices } from '@northern.tech/store/thunks'; import type { Device, Filter } from '@northern.tech/types/MenderTypes'; +import dayjs from 'dayjs'; +import validator from 'validator'; import type { DeploymentFormValues } from './types'; @@ -30,9 +32,23 @@ export const deploymentFormSections: Record phases: 'phases', release: 'release', retries: 'retries', + rolloutMode: 'rolloutMode', + startTime: 'startTime', + uniform_phases: 'uniform_phases', update_control_map: 'update_control_map' }; +export const getPhaseStartTime = (phases, index, startDate) => { + const startingDate = typeof startDate === 'string' && validator.isISO8601(startDate) ? startDate : undefined; + if (index < 1) { + return startDate?.toISOString ? startDate.toISOString() : startingDate; + } else if (phases[index].start_ts && typeof phases[index].start_ts === 'string' && validator.isISO8601(phases[index].start_ts)) { + return phases[index].start_ts; + } + const newStartTime = phases.slice(0, index).reduce((accu, phase) => dayjs(accu).add(phase.delay, phase.delayUnit), startingDate); + return newStartTime.toISOString(); +}; + export type DeploymentDerivedState = { deploymentDeviceCount: number; deploymentDeviceIds: string[]; From ee9afd785a91f27985ea274c2970bd2263674898 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Tue, 26 May 2026 23:11:58 +0200 Subject: [PATCH 03/20] feat(gui): added deployment phase definition input to align w/ updated design Signed-off-by: Manuel Zedel --- .../deployment-wizard/phases/Input.tsx | 112 ++++++++++++++++++ .../deployment-wizard/phases/constants.ts | 2 + 2 files changed, 114 insertions(+) create mode 100644 frontend/src/js/components/deployments/deployment-wizard/phases/Input.tsx diff --git a/frontend/src/js/components/deployments/deployment-wizard/phases/Input.tsx b/frontend/src/js/components/deployments/deployment-wizard/phases/Input.tsx new file mode 100644 index 000000000..0de4e660d --- /dev/null +++ b/frontend/src/js/components/deployments/deployment-wizard/phases/Input.tsx @@ -0,0 +1,112 @@ +// Copyright 2026 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { InfoOutlined as InfoIcon } from '@mui/icons-material'; +import type { SelectProps } from '@mui/material'; +import { FormHelperText, InputAdornment, MenuItem, Select, Tooltip, Typography } from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; + +import type { NumberFieldRootProps } from '@base-ui/react/number-field'; +import { NumberField } from '@northern.tech/common-ui/forms/NumberField'; +import pluralize from 'pluralize'; + +import { delayUnits, deviceCountTooltipThreshold } from './constants'; +import { type PhaseMessage, formatDeviceCount } from './utils'; + +const useStyles = makeStyles()(theme => ({ + batchInputWrapper: { alignItems: 'center', display: 'grid', gridTemplateColumns: '50% max-content max-content' }, + delayInputWrapper: { display: 'grid', gridTemplateColumns: 'min-content min-content', columnGap: theme.spacing() } +})); + +const maxDelayInputValue = 720; + +interface DelayInputProps { + delay: number; + delayUnit: keyof typeof delayUnits; + id: string; + onDelayChange: NumberFieldRootProps['onValueChange']; + onDelayUnitChange: SelectProps['onChange']; +} + +export const DelayInput = ({ id, delay, delayUnit, onDelayChange, onDelayUnitChange }: DelayInputProps) => { + const { classes } = useStyles(); + return ( +
+ + +
+ ); +}; + +interface BatchSizeInputProps { + deviceCount: number; + disabled?: boolean; + hasError?: boolean; + isPercentageMode: boolean; + max?: number; + messages?: PhaseMessage[]; + min?: number; + onChange: NumberFieldRootProps['onValueChange']; + value: number | undefined; +} + +export const BatchSizeInput = ({ + deviceCount, + value, + onChange, + isPercentageMode, + hasError = false, + max, + min = 1, + disabled = false, + messages = [] +}: BatchSizeInputProps) => { + const { classes } = useStyles(); + + return ( + <> +
+ % : undefined} + disabled={disabled} + error={hasError} + size="small" + step={1} + min={min} + max={max} + /> + + ({formatDeviceCount(deviceCount)} {pluralize('device', deviceCount)}) + + {!hasError && deviceCount >= deviceCountTooltipThreshold && ( + + + + )} +
+ {messages.map(({ message, severity }, i) => ( + + {message} + + ))} + + ); +}; diff --git a/frontend/src/js/components/deployments/deployment-wizard/phases/constants.ts b/frontend/src/js/components/deployments/deployment-wizard/phases/constants.ts index f9bbf4878..e996ffb07 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/phases/constants.ts +++ b/frontend/src/js/components/deployments/deployment-wizard/phases/constants.ts @@ -50,6 +50,8 @@ export const delayDefaults = { delayUnit: delayUnits.hours }; +export const deviceCountTooltipThreshold = 1000; + export const phaseLimits = { maxPerBatchPercentage: 99, fullBatchPercentage: 100, From 1c39855eaa06f050d9bd4577441407533ba5ef25 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Tue, 26 May 2026 23:12:02 +0200 Subject: [PATCH 04/20] feat(gui): added support for percentage & device count deployment phase definitions - also mode conversion + basic phase management actions Ticket: MEN-9001 Signed-off-by: Manuel Zedel --- .../deployment-wizard/phases/CustomPhases.tsx | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 frontend/src/js/components/deployments/deployment-wizard/phases/CustomPhases.tsx diff --git a/frontend/src/js/components/deployments/deployment-wizard/phases/CustomPhases.tsx b/frontend/src/js/components/deployments/deployment-wizard/phases/CustomPhases.tsx new file mode 100644 index 000000000..228ae3dbe --- /dev/null +++ b/frontend/src/js/components/deployments/deployment-wizard/phases/CustomPhases.tsx @@ -0,0 +1,228 @@ +// Copyright 2026 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { useEffect, useRef } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { Add as AddIcon, Close as CancelIcon, RepeatOutlined as RepeatIcon } from '@mui/icons-material'; +import { Button, IconButton, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; + +import Time from '@northern.tech/common-ui/Time'; +import type { Filter } from '@northern.tech/types/MenderTypes'; + +import type { DeploymentFormValues } from '../types'; +import { deploymentFormSections, getPhaseStartTime } from '../utils'; +import { BatchSizeInput, DelayInput } from './Input'; +import type { RolloutMode } from './constants'; +import { delayDefaults, delayUnits, phaseDefaults, rolloutModes } from './constants'; +import type { UiDeploymentPhase } from './utils'; +import { computePhaseInfo, devicesToPercentage, getRemainder, percentageToDevices, useRowStyles } from './utils'; + +const convertPhasesToMode = (phases, newMode: RolloutMode, numberDevices: number) => + phases.map((phase, index, source) => { + const isLast = index === source.length - 1; + if (newMode === rolloutModes.device_count.key) { + const { batch_size, ...rest } = phase; + if (!batch_size && isLast) return rest; + return { ...rest, batch_size_devices: percentageToDevices(batch_size || 0, numberDevices) }; + } + const { batch_size_devices, ...rest } = phase; + if (!batch_size_devices && isLast) return rest; + return { ...rest, batch_size: devicesToPercentage(batch_size_devices || 0, numberDevices) }; + }); + +const applyBatchSizeUpdate = (phases, value: number, index: number, isPercentageMode: boolean) => { + const newPhases = [...phases]; + const rounded = Math.max(0, Math.round(value)); + if (isPercentageMode) { + newPhases[index] = { ...newPhases[index], batch_size: rounded }; + } else { + newPhases[index] = { ...newPhases[index], batch_size_devices: rounded }; + } + return newPhases; +}; + +const evenSplitThreshold = 50; + +const tableHeaders = ['Phases', 'Batch size', 'Phase begins', 'Delay before next phase', '']; + +export const CustomPhaseTable = ({ filter, deploymentDeviceCount }: { deploymentDeviceCount: number; filter?: Filter }) => { + const { watch, setValue, getValues } = useFormContext(); + + const phases: Array = watch(deploymentFormSections.phases) || []; + const rolloutMode: RolloutMode = watch(deploymentFormSections.rolloutMode) || rolloutModes.percentage.key; + const maxDevices = watch(deploymentFormSections.maxDevices); + const isPercentageMode = rolloutMode === rolloutModes.percentage.key; + const batchKey = isPercentageMode ? rolloutModes.percentage.batchKey : rolloutModes.device_count.batchKey; + + const configuredStartTime = watch(deploymentFormSections.startTime); + const startTime = configuredStartTime ?? (phases.length ? phases[0].start_ts || new Date() : new Date()); + + const { classes } = useRowStyles(); + + const prevModeRef = useRef(rolloutMode); + useEffect(() => { + if (prevModeRef.current === rolloutMode) { + return; + } + prevModeRef.current = rolloutMode; + const currentPhases = getValues(deploymentFormSections.phases); + setValue(deploymentFormSections.phases, convertPhasesToMode(currentPhases, rolloutMode, deploymentDeviceCount)); + }, [rolloutMode, deploymentDeviceCount, setValue, getValues]); + + const updateDelay = (value, index) => { + const newPhases = [...phases]; + newPhases[index] = { ...newPhases[index], delay: Math.max(1, value) }; + setValue(deploymentFormSections.phases, newPhases); + }; + + const updateBatchSize = (value, index) => setValue(deploymentFormSections.phases, applyBatchSizeUpdate(phases, value, index, isPercentageMode)); + + const addPhase = () => { + const newPhases = [...phases]; + const remainder = getRemainder({ phases: newPhases, numberDevices: deploymentDeviceCount, rolloutMode }); + if (isPercentageMode) { + newPhases[newPhases.length - 1] = { + ...newPhases[newPhases.length - 1], + batch_size: remainder > phaseDefaults.batchSize ? phaseDefaults.batchSize : Math.floor(remainder / 2), + delay: newPhases[newPhases.length - 1].delay || delayDefaults.delay, + delayUnit: newPhases[newPhases.length - 1].delayUnit || delayUnits.hours + }; + } else { + const defaultBatch = + deploymentDeviceCount > 0 + ? Math.max(1, remainder > phaseDefaults.batchSize ? phaseDefaults.batchSize : Math.floor(remainder / 2)) + : phaseDefaults.batchSize; + newPhases[newPhases.length - 1] = { + ...newPhases[newPhases.length - 1], + batch_size_devices: defaultBatch, + delay: newPhases[newPhases.length - 1].delay || delayDefaults.delay, + delayUnit: newPhases[newPhases.length - 1].delayUnit || delayUnits.hours + }; + } + newPhases.push({}); + setValue(deploymentFormSections.phases, newPhases); + }; + + const removePhase = index => { + const newPhases = [...phases]; + newPhases.splice(index, 1); + const { [batchKey]: _removed, delay, ...newPhase } = newPhases[newPhases.length - 1]; + if (newPhases.length > 1) { + newPhase.delay = delay; + } + newPhases[newPhases.length - 1] = newPhase; + setValue(deploymentFormSections.phases, newPhases); + }; + + const repeatPhase = (index: number) => { + const newPhases = [...phases]; + const source = newPhases[index]; + const duplicate = { [batchKey]: source[batchKey], delay: source.delay || delayDefaults.delay, delayUnit: source.delayUnit || delayDefaults.delayUnit }; + if (isPercentageMode && source[batchKey] >= evenSplitThreshold) { + // distribute in 2 even phases instead of the regular handling + setValue(deploymentFormSections.phases, [ + { ...source, [batchKey]: evenSplitThreshold }, + { ...duplicate, [batchKey]: evenSplitThreshold } + ]); + return; + } + newPhases.splice(index + 1, 0, duplicate); + setValue(deploymentFormSections.phases, newPhases); + }; + + const handleDelayToggle = (value, index) => { + const newPhases = [...phases]; + newPhases[index] = { ...newPhases[index], delayUnit: value }; + setValue(deploymentFormSections.phases, newPhases); + }; + + const mappedPhases = phases.map((phase, index) => { + const { batchValue, deviceCount, hasError, hasWarning, max, messages } = computePhaseInfo({ + index, + isDynamic: !!filter, + phases, + numberDevices: deploymentDeviceCount, + rolloutMode, + maxDevices + }); + const isLast = index === phases.length - 1; + + return ( + + + {`Phase ${index + 1}`} + {isLast && phases.length > 1 && (Final step)} + + + updateBatchSize(value ?? 1, index)} + isPercentageMode={isPercentageMode} + hasError={hasError} + max={max} + disabled={isLast && deviceCount >= 1} + messages={messages} + /> + + + + + {phase.delay && !isLast ? ( + updateDelay(value ?? 1, index)} + onDelayUnitChange={({ target: { value } }) => handleDelayToggle(value, index)} + /> + ) : ( + '-' + )} + + + {!isLast && phases.length > 1 ? ( +
+ repeatPhase(index)} title="Repeat phase"> + + + removePhase(index)} title="Remove phase"> + + +
+ ) : null} +
+
+ ); + }); + + return ( + <> + + + + {tableHeaders.map((content, index) => ( + {content} + ))} + + + {mappedPhases} +
+ + + ); +}; From 754aacd402503389c020889f2d5ab24149f1d198 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Tue, 26 May 2026 23:12:06 +0200 Subject: [PATCH 05/20] feat(gui): add support for repeating uniform deployment phase patterns Ticket: MEN-9001 Signed-off-by: Manuel Zedel --- .../phases/UniformPhases.tsx | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 frontend/src/js/components/deployments/deployment-wizard/phases/UniformPhases.tsx diff --git a/frontend/src/js/components/deployments/deployment-wizard/phases/UniformPhases.tsx b/frontend/src/js/components/deployments/deployment-wizard/phases/UniformPhases.tsx new file mode 100644 index 000000000..1e3c31f67 --- /dev/null +++ b/frontend/src/js/components/deployments/deployment-wizard/phases/UniformPhases.tsx @@ -0,0 +1,194 @@ +// Copyright 2026 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { useEffect, useRef } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; + +import Time from '@northern.tech/common-ui/Time'; +import type { Filter } from '@northern.tech/types/MenderTypes'; +import dayjs from 'dayjs'; +import durationPlugin from 'dayjs/plugin/duration'; +import type { DurationUnitType } from 'dayjs/plugin/duration'; +import pluralize from 'pluralize'; + +import type { DeploymentFormValues } from '../types'; +import { deploymentFormSections, getPhaseStartTime } from '../utils'; +import { BatchSizeInput, DelayInput } from './Input'; +import type { RolloutMode } from './constants'; +import { delayDefaults, delayUnits, phaseDefaults, phaseLimits, rolloutModes } from './constants'; +import { computePhaseInfo, devicesToPercentage, getPhaseDeviceCount, percentageToDevices, useRowStyles } from './utils'; + +dayjs.extend(durationPlugin); + +const delayToSeconds = (delay: number, unit: string): string => `${dayjs.duration(delay, unit as dayjs.ManipulateType).asSeconds()}s`; + +export const parseInterval = (interval?: string): { delay: number; delayUnit: string } => { + if (!interval) return { ...delayDefaults }; + const seconds = dayjs.duration(parseInt(interval) || phaseDefaults.delay, 'seconds'); + return [delayUnits.days, delayUnits.hours, delayUnits.minutes].reduce( + (accu, unit) => { + const durationPerUnit = seconds.get(unit as DurationUnitType); + if (durationPerUnit >= 1 && Number.isInteger(durationPerUnit)) { + return { delay: durationPerUnit, delayUnit: unit }; + } + return accu; + }, + { delay: Math.max(1, Math.round(seconds.asHours())), delayUnit: delayUnits.hours } + ); +}; + +const uniformTableHeaders = ['Batch size', 'First phase begins', 'Delay before next phase']; + +const PhasesSummary = ({ deviceCount, delay, delayUnit, filter, isPercentageMode, batchSize }) => { + let phasesCount = Math.ceil(deviceCount / batchSize); + let perPhaseCount = batchSize; + let remainder = deviceCount % batchSize; + if (isPercentageMode) { + phasesCount = Math.ceil(100 / batchSize); + perPhaseCount = percentageToDevices(batchSize, deviceCount); + remainder = percentageToDevices(100 % batchSize, deviceCount); + } + const delayDescriptor = `${delay}-${pluralize(delayUnit, 1)} delay between phases`; + const totalDescriptor = `(${deviceCount} ${pluralize('device', deviceCount)} total)`; + return ( +
+ Summary + + {filter + ? `Deploy in phases of ${perPhaseCount.toLocaleString()} ${pluralize('device', perPhaseCount)}, with a ${delayDescriptor}` + : remainder + ? `${phasesCount - 1} ${pluralize('phase', phasesCount - 1)} with ${perPhaseCount.toLocaleString()} ${pluralize('device', perPhaseCount)}${phasesCount - 1 > 1 ? ' each' : ''} and a ${delayDescriptor}, plus a final phase with ${remainder} ${pluralize('device', remainder)} ${totalDescriptor}` + : `${phasesCount} ${pluralize('phase', phasesCount)} with ${perPhaseCount.toLocaleString()} ${pluralize('device', perPhaseCount)} ${totalDescriptor}`} + +
+ ); +}; + +export const UniformPhaseSettings = ({ filter, deploymentDeviceCount }: { deploymentDeviceCount: number; filter?: Filter }) => { + const { watch, setValue, getValues } = useFormContext(); + const rolloutMode: RolloutMode = watch(deploymentFormSections.rolloutMode) || rolloutModes.percentage.key; + const uniformPhases = watch(deploymentFormSections.uniform_phases); + const configuredStartTime = watch(deploymentFormSections.startTime); + const maxDevices = watch(deploymentFormSections.maxDevices); + const isPercentageMode = rolloutMode === rolloutModes.percentage.key; + + const consideredDevices = maxDevices ? maxDevices : deploymentDeviceCount; + const batchSize = uniformPhases?.batch_size ?? (isPercentageMode ? phaseDefaults.batchSize : undefined); + const batchDevices = + uniformPhases?.batch_size_devices ?? + (isPercentageMode ? undefined : Math.min(consideredDevices || phaseLimits.fallbackDeviceCount, phaseLimits.maxDefaultBatchDevices)); + const { delay, delayUnit } = parseInterval(uniformPhases?.time_interval); + + const currentBatch = isPercentageMode ? batchSize : batchDevices; + const deviceCount = isPercentageMode ? getPhaseDeviceCount(consideredDevices, batchSize, 0, false) : batchDevices || 0; + + const { classes } = useRowStyles(); + + const prevModeRef = useRef(rolloutMode); + useEffect(() => { + if (prevModeRef.current === rolloutMode) { + return; + } + prevModeRef.current = rolloutMode; + const current = getValues(deploymentFormSections.uniform_phases); + const interval = current?.time_interval || `${phaseDefaults.delay}s`; + if (rolloutMode === rolloutModes.device_count.key) { + const percentage = current?.batch_size || phaseDefaults.batchSize; + setValue(deploymentFormSections.uniform_phases, { batch_size_devices: percentageToDevices(percentage, consideredDevices), time_interval: interval }); + } else { + const devices = current?.batch_size_devices || Math.min(consideredDevices || phaseLimits.fallbackDeviceCount, phaseLimits.maxDefaultBatchDevices); + setValue(deploymentFormSections.uniform_phases, { batch_size: devicesToPercentage(devices, consideredDevices), time_interval: interval }); + } + }, [rolloutMode, consideredDevices, setValue, getValues]); + + const updateUniformPhases = (newBatch?: number, newBatchDevices?: number, newDelay?: number, newUnit?: string) => { + const nextDelay = newDelay ?? delay; + const unit = newUnit ?? delayUnit; + setValue(deploymentFormSections.uniform_phases, { + ...(isPercentageMode ? { batch_size: newBatch ?? batchSize } : { batch_size_devices: newBatchDevices ?? batchDevices }), + time_interval: delayToSeconds(nextDelay, unit) + }); + }; + + const handleBatchChange = (value: number) => { + if (isPercentageMode) { + updateUniformPhases(Math.min(phaseLimits.maxPerBatchPercentage, Math.max(1, value)), undefined); + } else { + updateUniformPhases(undefined, Math.max(1, value)); + } + }; + + const handleDelayChange = (value: number) => updateUniformPhases(undefined, undefined, Math.max(1, value)); + + const handleDelayUnitChange = ({ target: { value } }) => updateUniformPhases(undefined, undefined, undefined, value); + + const { hasError, hasWarning, max, messages } = computePhaseInfo({ + index: 0, + phases: [{}], + isDynamic: !!filter, + numberDevices: deviceCount, + rolloutMode, + maxDevices + }); + + return ( +
+ + + + {uniformTableHeaders.map((content, index) => ( + {content} + ))} + + + + + + handleBatchChange(value ?? 1)} + isPercentageMode={isPercentageMode} + deviceCount={deviceCount} + max={isPercentageMode ? phaseLimits.maxPerBatchPercentage : maxDevices ? max : deploymentDeviceCount} + hasError={hasError} + messages={messages} + /> + + + + + handleDelayChange(value ?? 1)} + onDelayUnitChange={handleDelayUnitChange} + /> + + + +
+ +
+ ); +}; From 87580109cb08633bf47bc77231e6b9537590a78b Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Wed, 27 May 2026 11:46:28 +0200 Subject: [PATCH 06/20] refactor(gui): track deployment schedule independent of phase definitions Signed-off-by: Manuel Zedel --- .../deployment-wizard/ScheduleRollout.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/frontend/src/js/components/deployments/deployment-wizard/ScheduleRollout.tsx b/frontend/src/js/components/deployments/deployment-wizard/ScheduleRollout.tsx index b44be8011..314880f4b 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/ScheduleRollout.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/ScheduleRollout.tsx @@ -38,19 +38,9 @@ export const ScheduleRollout = ({ canSchedule, commonClasses, open = false }) => const { classes } = useStyles(); const { watch, setValue } = useFormContext(); - const phases = watch(deploymentFormSections.phases) || []; + const startTime = watch(deploymentFormSections.startTime); - const handleStartTimeChange = value => { - // if there is no existing phase, set phase and start time - if (!phases.length) { - setValue(deploymentFormSections.phases, [{ batch_size: 100, start_ts: value, delay: 0 }]); - } else { - //if there are existing phases, set the first phases to the new start time and adjust later phases in different function - const newPhases = [...phases]; - newPhases[0] = { ...newPhases[0], start_ts: value }; - setValue(deploymentFormSections.phases, newPhases); - } - }; + const handleStartTimeChange = (value?: string) => setValue(deploymentFormSections.startTime, value); const handleStartChange = event => { // To be used with updated datetimepicker to open programmatically @@ -61,9 +51,6 @@ export const ScheduleRollout = ({ canSchedule, commonClasses, open = false }) => } }; - const start_time = phases.length ? phases[0].start_ts : undefined; - - const startTime = dayjs(start_time); return ( <>
@@ -74,7 +61,7 @@ export const ScheduleRollout = ({ canSchedule, commonClasses, open = false }) =>
- Start immediately Schedule the start date & time @@ -83,7 +70,7 @@ export const ScheduleRollout = ({ canSchedule, commonClasses, open = false }) =>
- {Boolean(isPickerOpen || start_time) && ( + {Boolean(isPickerOpen || startTime) && ( disabled={!canSchedule} onChange={date => handleStartTimeChange(date.toISOString())} slotProps={{ textField: { style: { minWidth: 400 } } }} - value={startTime} + value={dayjs(startTime)} /> )} From 25aa76aa021c7c008c8bb6b4a80074cb95e759f8 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Thu, 6 Aug 2026 14:26:36 +0200 Subject: [PATCH 07/20] refactor(gui): integrated custom phase pattern support in phase settings Signed-off-by: Manuel Zedel --- .../deployment-wizard/PhaseSettings.tsx | 400 ++++++------------ 1 file changed, 118 insertions(+), 282 deletions(-) diff --git a/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx b/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx index a9628b6d3..f0dd87451 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx @@ -14,321 +14,139 @@ import { useCallback, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { Add as AddIcon, Cancel as CancelIcon } from '@mui/icons-material'; import { + Alert, Checkbox, - Chip, Collapse, FormControl, FormControlLabel, - IconButton, - InputAdornment, ListSubheader, MenuItem, + Radio, + RadioGroup, Select, - Table, - TableBody, - TableCell, - TableHead, - TableRow + Tooltip, + Typography } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { DOCSTIPS, DocsTooltip } from '@northern.tech/common-ui/DocsLink'; +import { DOCSTIPS, DocsTextLink } from '@northern.tech/common-ui/DocsLink'; import EnterpriseNotification from '@northern.tech/common-ui/EnterpriseNotification'; import { InfoHintContainer } from '@northern.tech/common-ui/InfoHint'; -import Time from '@northern.tech/common-ui/Time'; -import { NumberField } from '@northern.tech/common-ui/forms/NumberField'; -import { BENEFITS } from '@northern.tech/store/constants'; -import dayjs from 'dayjs'; -import pluralize from 'pluralize'; -import validator from 'validator'; +import { ALL_DEVICES, BENEFITS } from '@northern.tech/store/constants'; +import { isDarkMode } from '@northern.tech/store/utils'; +import type { Filter } from '@northern.tech/types/MenderTypes'; +import { CustomPhaseTable } from './phases/CustomPhases'; +import type { RolloutPattern } from './phases/constants'; +import { + type RolloutMode, + delayDefaults, + delayUnits, + phaseDefaults, + phaseLimits, + rolloutModes, + rolloutPatterns as rolloutPatternDefinitions +} from './phases/constants'; +import { getPhasesMessage, toPhaseDescription } from './phases/utils'; import type { DeploymentFormValues } from './types'; import { deploymentFormSections, useDerivedData } from './utils'; -// use this to get remaining percent of final phase so we don't set a hard number -export const getRemainderPercent = phases => - phases.reduce((accu, phase, index, source) => { - // ignore final phase size if set - if (index === source.length - 1) { - return accu; - } - return phase.batch_size ? accu - phase.batch_size : accu; - }, 100); - -export const validatePhases = (phases, deploymentDeviceCount) => { - if (!phases?.length) { - return true; - } - const remainder = getRemainderPercent(phases); - const { isValid } = phases.reduce( - (accu, { batch_size = 0 }) => { - if (!accu.isValid) { - return accu; - } - const deviceCount = Math.floor((deploymentDeviceCount / 100) * (batch_size || remainder)); - const totalSize = accu.totalSize + batch_size; - return { isValid: deviceCount >= 1 && totalSize <= 100, totalSize }; - }, - { isValid: true, totalSize: 0 } - ); - return isValid; -}; - -export const getPhaseDeviceCount = (numberDevices = 1, batchSize, remainder, isLastPhase) => - isLastPhase ? Math.ceil((numberDevices / 100) * (batchSize || remainder)) : Math.floor((numberDevices / 100) * (batchSize || remainder)); - const useStyles = makeStyles()(theme => ({ - chip: { marginTop: theme.spacing(2) }, - delayInputWrapper: { display: 'grid', gridTemplateColumns: 'max-content max-content', columnGap: theme.spacing() }, - row: { whiteSpace: 'nowrap' }, - input: { minWidth: 400 }, - patternSelection: { marginTop: theme.spacing(2), maxWidth: 515, width: 'min-content' } + container: { + background: isDarkMode(theme.palette.mode) ? theme.palette.info.dark : theme.palette.info.light + }, + patternSelection: { marginTop: theme.spacing(2), width: 400 } })); -const timeframes = ['minutes', 'hours', 'days']; -const tableHeaders = ['', 'Batch size', 'Phase begins', 'Delay before next phase', '']; +const rolloutPatterns = { + [rolloutPatternDefinitions.custom.key]: { ...rolloutPatternDefinitions.custom, component: CustomPhaseTable } +}; -export const getPhaseStartTime = (phases, index, startDate) => { - const startingDate = typeof startDate === 'string' && validator.isISO8601(startDate) ? startDate : undefined; - if (index < 1) { - return startDate?.toISOString ? startDate.toISOString() : startingDate; - } else if (phases[index].start_ts && typeof phases[index].start_ts === 'string' && validator.isISO8601(phases[index].start_ts)) { - // if displaying an ongoing deployment we can rely on the timing info from the backend - return phases[index].start_ts; +const getDefaultPhasesForPattern = ( + rolloutMode: RolloutMode, + patternValue: string, + numberDevices: number, + deploymentDeviceCount: number, + filter: Filter, + phaseStart: Record +) => { + if (rolloutMode === rolloutModes.device_count.key) { + const defaultBatch = numberDevices > 0 ? Math.max(1, Math.min(numberDevices, phaseDefaults.batchSize)) : phaseDefaults.batchSize; + if (patternValue === rolloutPatterns.custom.key) + return [{ batch_size_devices: defaultBatch, delay: delayDefaults.delay, delayUnit: delayUnits.hours, ...phaseStart }, {}]; + return null; } - // since we don't want to get stale phase start times when the creation dialog is open for a long time - // we have to ensure start times are based on delay from previous phases - // since there likely won't be 1000s of phases this should still be fine to recalculate - const newStartTime = phases.slice(0, index).reduce((accu, phase) => dayjs(accu).add(phase.delay, phase.delayUnit), startingDate); - return newStartTime.toISOString(); + const minBatch = deploymentDeviceCount < phaseDefaults.batchSize && !filter ? Math.ceil((1 / deploymentDeviceCount) * 100) : phaseDefaults.batchSize; + if (patternValue === rolloutPatterns.custom.key) + return [{ batch_size: minBatch, delay: delayDefaults.delay, delayUnit: delayUnits.hours, ...phaseStart }, {}]; + return null; }; -export const PhaseSettings = ({ classNames, disabled, numberDevices }) => { - const { classes } = useStyles(); - const { watch, setValue } = useFormContext(); - const { filter } = useDerivedData(watch); +interface RolloutPatternSelectionProps { + isEnterprise: boolean; + previousPhases?: Array>>; +} +export const RolloutPatternSelection = ({ isEnterprise, previousPhases = [] }: RolloutPatternSelectionProps) => { + const { watch, setValue, getValues } = useFormContext(); + const { deploymentDeviceCount, deploymentDeviceIds, filter } = useDerivedData(watch); const phases = watch(deploymentFormSections.phases) || []; + const rolloutMode: RolloutMode = watch(deploymentFormSections.rolloutMode) || rolloutModes.percentage.key; + const configuredStartTime = watch(deploymentFormSections.startTime); + const maxDevices = watch(deploymentFormSections.maxDevices); + const group = watch(deploymentFormSections.group); - const updateDelay = (value, index) => { - const newPhases = [...phases]; - // value must be at least 1 - value = Math.max(1, value); - newPhases[index] = { ...newPhases[index], delay: value }; - setValue(deploymentFormSections.phases, newPhases); - // logic for updating time stamps should be in parent - only change delays here - }; - - const updateBatchSize = (value, index) => { - const newPhases = [...phases]; - value = Math.min(100, Math.max(1, value)); - newPhases[index] = { - ...newPhases[index], - batch_size: value - }; - // When phase's batch size changes, check for new 'remainder' - const remainder = getRemainderPercent(newPhases); - // if new remainder will be 0 or negative remove phase leave last phase to get remainder - if (remainder < 1) { - newPhases.pop(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { batch_size, ...newFinalPhase } = newPhases[newPhases.length - 1]; - newPhases[newPhases.length - 1] = newFinalPhase; - } - setValue(deploymentFormSections.phases, newPhases); - }; - - const addPhase = () => { - const newPhases = [...phases]; - // assign new batch size to *previous* last batch - const remainder = getRemainderPercent(newPhases); - newPhases[newPhases.length - 1] = { - ...newPhases[newPhases.length - 1], - // make it default 10, unless remainder is <=10 in which case make it half remainder - batch_size: remainder > 10 ? 10 : Math.floor(remainder / 2), - // check for previous phase delay or set 2hr default - delay: newPhases[newPhases.length - 1].delay || 2, - delayUnit: newPhases[newPhases.length - 1].delayUnit || 'hours' - }; - newPhases.push({}); - // use function to set new phases incl start time of new phase - setValue(deploymentFormSections.phases, newPhases); - }; + const [usesPattern, setUsesPattern] = useState(phases.some(i => i)); + const { classes } = useStyles(); - const removePhase = index => { - const newPhases = [...phases]; - newPhases.splice(index, 1); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { batch_size, delay, ...newPhase } = newPhases[newPhases.length - 1]; - if (newPhases.length > 1) { - newPhase.delay = delay; - } - newPhases[newPhases.length - 1] = newPhase; - setValue(deploymentFormSections.phases, newPhases); - }; + const numberDevices = deploymentDeviceCount ? deploymentDeviceCount : deploymentDeviceIds ? deploymentDeviceIds.length : 0; + const isEmptyGroup = numberDevices === 0 && !filter && group !== ALL_DEVICES; - const handleDelayToggle = (value, index) => { - const newPhases = [...phases]; - newPhases[index] = { - ...newPhases[index], - delayUnit: value - }; - setValue(deploymentFormSections.phases, newPhases); - }; + const activePattern = rolloutPatterns.custom.key as RolloutPattern; - const remainder = getRemainderPercent(phases); + const handlePatternChange = ({ target: { value } }) => { + const startTime = configuredStartTime ?? (phases.length ? phases[0].start_ts : undefined); + const phaseStart = { start_ts: startTime }; - // disable 'add phase' button if last phase/remainder has only 1 device left - const disableAdd = !filter && (remainder / 100) * numberDevices <= 1; - const startTime = phases.length ? phases[0].start_ts || new Date() : new Date(); - const mappedPhases = phases.map((phase, index) => { - const max = index > 0 ? 100 - phases[index - 1].batch_size : 100; - const deviceCount = getPhaseDeviceCount(numberDevices, phase.batch_size, remainder, index === phases.length - 1); - const isEmptyPhase = deviceCount < 1; - return ( - - - - - -
- {phase.batch_size && phase.batch_size < 100 ? ( - updateBatchSize(value ?? 1, index)} - endAdornment={ - - % - - } - disabled={disabled && deviceCount >= 1} - step={1} - min={1} - max={max} - /> - ) : ( - phase.batch_size || remainder - )} - {`(${deviceCount} ${pluralize( - 'device', - deviceCount - )})`} -
- {isEmptyPhase &&
Phases must have at least 1 device
} -
- - - - {phase.delay && index !== phases.length - 1 ? ( -
- updateDelay(value ?? 1, index)} - min={1} - max={720} - /> - -
- ) : ( - '-' - )} -
- - {index >= 1 ? ( - removePhase(index)} size="large"> - - - ) : null} - -
+ const defaultPhases = getDefaultPhasesForPattern(rolloutMode, value, numberDevices, deploymentDeviceCount, filter, phaseStart); + setValue( + deploymentFormSections.phases, + defaultPhases ?? (Array.isArray(value) ? structuredClone(value) : [{ batch_size: phaseLimits.fullBatchPercentage }]) ); - }); - - return ( -
- - - - {tableHeaders.map((content, index) => ( - {content} - ))} - - - {mappedPhases} -
- - {!disableAdd ? } label="Add a phase" onClick={addPhase} /> : null} -
- ); -}; - -export const RolloutPatternSelection = props => { - const { disableSchedule, isEnterprise, open = false, previousPhases = [] } = props; - const { watch, setValue } = useFormContext(); - const { deploymentDeviceCount, deploymentDeviceIds, filter } = useDerivedData(watch); - const phases = watch(deploymentFormSections.phases) || []; - - const [usesPattern, setUsesPattern] = useState(open || phases.some(i => i)); - const { classes } = useStyles(); - - const handlePatternChange = ({ target: { value } }) => { - let updatedPhases = []; - // check if a start time already exists from props and if so, use it - const phaseStart = phases.length ? { start_ts: phases[0].start_ts } : {}; - // if setting new custom pattern we use default 2 phases - // for small groups get minimum batch size containing at least 1 device - const minBatch = deploymentDeviceCount < 10 && !filter ? Math.ceil((1 / deploymentDeviceCount) * 100) : 10; - switch (value) { - case 0: - updatedPhases = [{ batch_size: 100, ...phaseStart }]; - break; - case 1: - updatedPhases = [{ batch_size: minBatch, delay: 2, delayUnit: 'hours', ...phaseStart }, {}]; - break; - default: - updatedPhases = JSON.parse(JSON.stringify(value)); - break; - } - setValue(deploymentFormSections.phases, updatedPhases); }; const onUsesPatternClick = useCallback(() => { if (usesPattern) { - setValue(deploymentFormSections.phases, phases.slice(0, 1)); + const currentPhases = getValues(deploymentFormSections.phases) || []; + const singlePhase = currentPhases.length > 0 ? currentPhases.slice(0, 1) : [{ batch_size: phaseLimits.fullBatchPercentage }]; + setValue(deploymentFormSections.phases, singlePhase); + } else { + const currentPhases = getValues(deploymentFormSections.phases) || []; + if (currentPhases.length < 2) { + const startTime = configuredStartTime ?? (currentPhases.length ? currentPhases[0].start_ts : undefined); + const defaultPhases = getDefaultPhasesForPattern(rolloutMode, rolloutPatterns.custom.key, numberDevices, deploymentDeviceCount, filter, { + start_ts: startTime + }); + if (defaultPhases) { + setValue(deploymentFormSections.phases, defaultPhases); + } + } } setUsesPattern(!usesPattern); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [usesPattern, JSON.stringify(phases), setValue, setUsesPattern]); + }, [usesPattern, getValues, setValue, configuredStartTime, rolloutMode, numberDevices, deploymentDeviceCount, filter]); - const numberDevices = deploymentDeviceCount ? deploymentDeviceCount : deploymentDeviceIds ? deploymentDeviceIds.length : 0; - const customPattern = phases && phases.length > 1 ? 1 : 0; + const handleModeChange = ({ target: { value } }: React.ChangeEvent) => setValue(deploymentFormSections.rolloutMode, value); const previousPhaseOptions = previousPhases.length > 0 ? previousPhases.map((previousPhaseSetting, index) => { - const remainder = getRemainderPercent(previousPhaseSetting); - const phaseDescription = previousPhaseSetting.reduce( - (accu, phase, _, source) => { - const phaseDescription = phase.delay - ? `${phase.batch_size}% > ${phase.delay} ${phase.delayUnit || 'hours'} >` - : `${phase.batch_size || remainder || 100 / source.length}%`; - return `${accu} ${phaseDescription}`; - }, - `${previousPhaseSetting.length} ${pluralize('phase', previousPhaseSetting.length)}:` - ); + const { phasesDescription, tooltip } = toPhaseDescription(previousPhaseSetting, numberDevices); return ( - {phaseDescription} + +
{phasesDescription}
+
); }) @@ -337,37 +155,55 @@ export const RolloutPatternSelection = props => { No recent patterns ]; + + const phasesNotification = getPhasesMessage({ filter, rolloutPattern: activePattern, maxDevices }); + + const { component: ActivePatternComponent } = rolloutPatterns[activePattern]; return ( <> } + control={} label={
- Select a rollout pattern (optional) + Select a rollout pattern + -
} /> - + - {(numberDevices > 1 || filter) && [ - - Custom - , + ...Object.values(rolloutPatterns).map(({ key, tip, title }) => ( + + +
{title}
+
+
+ )), Recent patterns, ...previousPhaseOptions ]}
+
+ + Rollout phases: + {Object.values(rolloutModes).map(({ key, title }) => ( + } label={title} /> + ))} + + + {phasesNotification && ( + + {phasesNotification.message} + + )} +
- {customPattern ? : null} ); }; - -export default PhaseSettings; From 9f55fcccd90cff0edf0bb3bcf0e56e8c9e523054 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Wed, 27 May 2026 12:05:51 +0200 Subject: [PATCH 08/20] refactor(gui): allow deployment creation despite faulty phase definitions Signed-off-by: Manuel Zedel --- frontend/src/js/components/deployments/CreateDeployment.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/js/components/deployments/CreateDeployment.tsx b/frontend/src/js/components/deployments/CreateDeployment.tsx index 39075ebde..da21ae602 100644 --- a/frontend/src/js/components/deployments/CreateDeployment.tsx +++ b/frontend/src/js/components/deployments/CreateDeployment.tsx @@ -56,7 +56,7 @@ import pluralize from 'pluralize'; import { getOnboardingComponentFor } from '../../utils/onboardingManager'; import DeviceLimit from './deployment-wizard/DeviceLimit'; -import { RolloutPatternSelection, getPhaseStartTime, validatePhases } from './deployment-wizard/PhaseSettings'; +import { RolloutPatternSelection, getPhaseStartTime } from './deployment-wizard/PhaseSettings'; import { ForceDeploy, Retries, RolloutOptions } from './deployment-wizard/RolloutOptions'; import { ScheduleRollout } from './deployment-wizard/ScheduleRollout'; import { Devices, ReleasesWarning, Software } from './deployment-wizard/SoftwareDevices'; @@ -261,7 +261,7 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS }); }; - const disabled = isCreating.current || !(release && (deploymentDeviceCount || !!filter || group)) || !validatePhases(phases, deploymentDeviceCount); + const disabled = isCreating.current || !(release && (deploymentDeviceCount || !!filter || group)); const hasReleases = !!Object.keys(releasesById).length; return ( From 30a07077299886a97610b76bb673ad04bba259c4 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Wed, 27 May 2026 12:00:16 +0200 Subject: [PATCH 09/20] refactor(gui): integrate rollout modes into deployment creation - extracted phase payload assembly to reduce cognitive complexity Signed-off-by: Manuel Zedel --- .../deployments/CreateDeployment.tsx | 29 +++++++++------- .../deployments/deployment-wizard/utils.ts | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/frontend/src/js/components/deployments/CreateDeployment.tsx b/frontend/src/js/components/deployments/CreateDeployment.tsx index da21ae602..2e7c0d438 100644 --- a/frontend/src/js/components/deployments/CreateDeployment.tsx +++ b/frontend/src/js/components/deployments/CreateDeployment.tsx @@ -56,12 +56,13 @@ import pluralize from 'pluralize'; import { getOnboardingComponentFor } from '../../utils/onboardingManager'; import DeviceLimit from './deployment-wizard/DeviceLimit'; -import { RolloutPatternSelection, getPhaseStartTime } from './deployment-wizard/PhaseSettings'; +import { RolloutPatternSelection } from './deployment-wizard/PhaseSettings'; import { ForceDeploy, Retries, RolloutOptions } from './deployment-wizard/RolloutOptions'; import { ScheduleRollout } from './deployment-wizard/ScheduleRollout'; import { Devices, ReleasesWarning, Software } from './deployment-wizard/SoftwareDevices'; +import { rolloutModes } from './deployment-wizard/phases/constants'; import type { DeploymentFormValues } from './deployment-wizard/types'; -import { deploymentFormSections, useDerivedData } from './deployment-wizard/utils'; +import { buildPhasePayload, deploymentFormSections, useDerivedData } from './deployment-wizard/utils'; const useStyles = makeStyles()(theme => ({ accordion: { @@ -101,6 +102,8 @@ export const defaultValues: DeploymentFormValues = { maxDevices: 0, retries: 1, phases: [], + rolloutMode: rolloutModes.percentage.key, + uniform_phases: undefined, update_control_map: { states: {} } }; @@ -152,9 +155,12 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS } }, [dispatch, isEnterprise, isHosted]); - const { group, phases, release } = formValues; + const { group, release } = formValues; useEffect(() => { if (open) { + const inferredMode = + deploymentObject.rolloutMode ?? + (deploymentObject.phases?.some(({ batch_size_devices }) => batch_size_devices !== null) ? rolloutModes.device_count.key : rolloutModes.percentage.key); reset({ group: deploymentObject.group ?? defaultValues.group, release: deploymentObject.release ?? defaultValues.release, @@ -163,6 +169,9 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS maxDevices: deploymentObject.maxDevices ?? defaultValues.maxDevices, retries: (deploymentObject.retries ?? previousRetries ?? 0) + 1, phases: deploymentObject.phases ?? defaultValues.phases, + rolloutMode: inferredMode, + startTime: deploymentObject.startTime ?? defaultValues.startTime, + uniform_phases: deploymentObject.uniform_phases ?? defaultValues.uniform_phases, update_control_map: deploymentObject.update_control_map ?? defaultValues.update_control_map }); } @@ -188,6 +197,9 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS forceDeploy: formValues.forceDeploy, maxDevices: formValues.maxDevices, phases: formValues.phases, + startTime: formValues.startTime, + rolloutMode: formValues.rolloutMode, + uniform_phases: formValues.uniform_phases, update_control_map: formValues.update_control_map }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -223,10 +235,10 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS return setIsChecking(true); } isCreating.current = true; - const { delta, forceDeploy = false, maxDevices, phases, release, update_control_map } = formValues; + const { delta, forceDeploy = false, maxDevices, phases, release, rolloutMode, startTime, uniform_phases, update_control_map } = formValues; const retries = (formValues.retries ?? 1) - 1; - const startTime = phases?.length ? phases[0].start_ts : undefined; const retrySetting = canRetry && retries ? { retries } : {}; + const phasePayload = buildPhasePayload({ phases, rolloutMode, startTime, uniform_phases }); const newDeployment = { artifact_name: release.name, autogenerate_delta: delta ? delta : undefined, @@ -236,12 +248,7 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS group: group === ALL_DEVICES || devices.length ? undefined : group, max_devices: maxDevices ? maxDevices : undefined, name: devices[0]?.id || (group ? decodeURIComponent(group) : ALL_DEVICES), - phases: phases.length - ? phases.map((phase, i, origPhases) => { - phase.start_ts = getPhaseStartTime(origPhases, i, startTime); - return phase; - }) - : undefined, + ...phasePayload, ...retrySetting, force_installation: forceDeploy, update_control_map: !isEmpty(update_control_map.states) ? update_control_map : undefined diff --git a/frontend/src/js/components/deployments/deployment-wizard/utils.ts b/frontend/src/js/components/deployments/deployment-wizard/utils.ts index 0462241aa..5c38ab039 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/utils.ts +++ b/frontend/src/js/components/deployments/deployment-wizard/utils.ts @@ -22,6 +22,7 @@ import type { Device, Filter } from '@northern.tech/types/MenderTypes'; import dayjs from 'dayjs'; import validator from 'validator'; +import { phaseLimits, rolloutModes } from './phases/constants'; import type { DeploymentFormValues } from './types'; export const deploymentFormSections: Record = { @@ -49,6 +50,38 @@ export const getPhaseStartTime = (phases, index, startDate) => { return newStartTime.toISOString(); }; +export const buildPhasePayload = ({ + phases = [], + rolloutMode, + startTime, + uniform_phases +}: Pick) => { + if (uniform_phases) { + return { + phases: undefined, + uniform_phases: startTime ? { ...uniform_phases, start_ts: startTime } : uniform_phases + }; + } + if (phases.length) { + return { + uniform_phases: undefined, + phases: phases.map((phase, i, origPhases) => { + const { batch_size, batch_size_devices, start_ts: _st, delay: _d, delayUnit: _du, ...rest } = phase; + return { + ...rest, + start_ts: getPhaseStartTime(origPhases, i, startTime), + ...(rolloutMode === rolloutModes.device_count.key ? { batch_size_devices } : { batch_size }) + }; + }) + }; + } + if (startTime) { + // if there is no existing phase, set phase and start time + return { phases: [{ batch_size: phaseLimits.fullBatchPercentage, start_ts: startTime }], uniform_phases: undefined }; + } + return { phases: undefined, uniform_phases: undefined }; +}; + export type DeploymentDerivedState = { deploymentDeviceCount: number; deploymentDeviceIds: string[]; From 24784c9b6d593ed045e8aa9d3d5efe0083f7cbc4 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Thu, 6 Aug 2026 14:27:14 +0200 Subject: [PATCH 10/20] feat(gui): support device-count rollouts in deployment progress Signed-off-by: Manuel Zedel --- .../deployment-report/RolloutSchedule.tsx | 13 +++++++++---- .../deployments/progress/usePhaseProgress.ts | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/frontend/src/js/components/deployments/deployment-report/RolloutSchedule.tsx b/frontend/src/js/components/deployments/deployment-report/RolloutSchedule.tsx index 3efd85bac..7429c1b64 100644 --- a/frontend/src/js/components/deployments/deployment-report/RolloutSchedule.tsx +++ b/frontend/src/js/components/deployments/deployment-report/RolloutSchedule.tsx @@ -25,7 +25,9 @@ import durationDayJs from 'dayjs/plugin/duration'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import pluralize from 'pluralize'; -import { getPhaseDeviceCount, getPhaseStartTime, getRemainderPercent } from '../deployment-wizard/PhaseSettings'; +import { rolloutModes } from '../deployment-wizard/phases/constants'; +import { getPhaseDeviceCount, getRemainder } from '../deployment-wizard/phases/utils'; +import { getPhaseStartTime } from '../deployment-wizard/utils'; import { RolloutProgressBar } from '../progress/RolloutProgressBar'; import { SubstateProgressBar } from '../progress/SubstateProgressBar'; import { getDeploymentPhasesInfo } from '../progress/usePhaseProgress'; @@ -82,13 +84,16 @@ export const RolloutSchedule = ({ deployment, innerRef, onAbort, onUpdateControl )}
{phases.map((phase, index) => { - const batchSize = phase.batch_size || getRemainderPercent(phases); - const deviceCount = getPhaseDeviceCount(totalDeviceCount, batchSize, batchSize, index === phases.length - 1); + const isPercentageMode = phase.hasOwnProperty(rolloutModes.percentage.batchKey); + const batchSize = isPercentageMode + ? phase.batch_size || getRemainder({ phases, numberDevices: totalDeviceCount, rolloutMode: rolloutModes.percentage.key }) + : phase.batch_size_devices || getRemainder({ phases, numberDevices: totalDeviceCount, rolloutMode: rolloutModes.device_count.key }); + const deviceCount = isPercentageMode ? getPhaseDeviceCount(totalDeviceCount, batchSize, batchSize, index === phases.length - 1) : batchSize; const deviceCountText = !filter ? ` (${deviceCount} ${pluralize('device', deviceCount)})` : ''; const startTime = phase.start_ts ?? getPhaseStartTime(phases, index, start_time); const phaseObject = { 'Phase start time':
+ } + /> )} diff --git a/frontend/src/js/components/deployments/deployment-wizard/DeviceLimit.tsx b/frontend/src/js/components/deployments/deployment-wizard/DeviceLimit.tsx index 4e32ca392..ad4d08bad 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/DeviceLimit.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/DeviceLimit.tsx @@ -14,33 +14,21 @@ import { useEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { Checkbox, Collapse, FormControlLabel } from '@mui/material'; -import { makeStyles } from 'tss-react/mui'; +import { Checkbox, Collapse, FormControlLabel, FormHelperText } from '@mui/material'; -import { DOCSTIPS, DocsTooltip } from '@northern.tech/common-ui/DocsLink'; +import { DOCSTIPS, DocsTextLink } from '@northern.tech/common-ui/DocsLink'; import { InfoHintContainer } from '@northern.tech/common-ui/InfoHint'; import { NumberInput } from '@northern.tech/common-ui/forms/NumberInput'; import type { DeploymentFormValues } from './types'; import { deploymentFormSections, useDerivedData } from './utils'; -const useStyles = makeStyles()(theme => ({ - limitSelection: { - alignItems: 'baseline', - display: 'flex', - marginTop: theme.spacing(2), - marginLeft: `calc(1em + ${theme.spacing(1.5)})` - } -})); - export const DeviceLimit = () => { const { setValue, watch } = useFormContext(); const { deploymentDeviceCount, deploymentDeviceIds, filter } = useDerivedData(watch); const numberDevices = deploymentDeviceCount ? deploymentDeviceCount : deploymentDeviceIds ? deploymentDeviceIds.length : 0; const [shouldLimit, setShouldLimit] = useState(false); - const { classes } = useStyles(); - useEffect(() => { if (!filter) { setValue(deploymentFormSections.maxDevices, 0); @@ -60,29 +48,30 @@ export const DeviceLimit = () => { return ( <> } + control={} label={
- Limit deployment to a maximum number of devices (optional) + Limit deployment to a maximum number of devices - +
} /> -
- Finish deployment after{' '} - !shouldLimit || (Number(value) >= 1 && !isNaN(Number(value))) || 'Please enter a valid number.' - }} - />{' '} - devices have attempted to apply the update -
+ !shouldLimit || (Number(value) >= 1 && !isNaN(Number(value))) || 'Please enter a valid number.' + }} + /> + + The deployment will automatically finish after this many devices have attempted to update. +
); diff --git a/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx b/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx index f0dd87451..734189b3d 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx @@ -14,25 +14,13 @@ import { useCallback, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { - Alert, - Checkbox, - Collapse, - FormControl, - FormControlLabel, - ListSubheader, - MenuItem, - Radio, - RadioGroup, - Select, - Tooltip, - Typography -} from '@mui/material'; +import { Alert, Collapse, FormControl, FormControlLabel, ListSubheader, MenuItem, Radio, RadioGroup, Select, Tooltip, Typography } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; import { DOCSTIPS, DocsTextLink } from '@northern.tech/common-ui/DocsLink'; import EnterpriseNotification from '@northern.tech/common-ui/EnterpriseNotification'; import { InfoHintContainer } from '@northern.tech/common-ui/InfoHint'; +import { FormCheckbox } from '@northern.tech/common-ui/forms/FormCheckbox'; import { ALL_DEVICES, BENEFITS } from '@northern.tech/store/constants'; import { isDarkMode } from '@northern.tech/store/utils'; import type { Filter } from '@northern.tech/types/MenderTypes'; @@ -116,14 +104,13 @@ export const RolloutPatternSelection = ({ isEnterprise, previousPhases = [] }: R ); }; - const onUsesPatternClick = useCallback(() => { - if (usesPattern) { - const currentPhases = getValues(deploymentFormSections.phases) || []; - const singlePhase = currentPhases.length > 0 ? currentPhases.slice(0, 1) : [{ batch_size: phaseLimits.fullBatchPercentage }]; - setValue(deploymentFormSections.phases, singlePhase); - } else { + const onUsesPatternClick = useCallback( + ({ target: { checked } }: React.MouseEvent & { target: HTMLInputElement }) => { const currentPhases = getValues(deploymentFormSections.phases) || []; - if (currentPhases.length < 2) { + if (!checked) { + const singlePhase = currentPhases.length > 0 ? currentPhases.slice(0, 1) : [{ batch_size: phaseLimits.fullBatchPercentage }]; + setValue(deploymentFormSections.phases, singlePhase); + } else if (currentPhases.length < 2) { const startTime = configuredStartTime ?? (currentPhases.length ? currentPhases[0].start_ts : undefined); const defaultPhases = getDefaultPhasesForPattern(rolloutMode, rolloutPatterns.custom.key, numberDevices, deploymentDeviceCount, filter, { start_ts: startTime @@ -132,9 +119,9 @@ export const RolloutPatternSelection = ({ isEnterprise, previousPhases = [] }: R setValue(deploymentFormSections.phases, defaultPhases); } } - } - setUsesPattern(!usesPattern); - }, [usesPattern, getValues, setValue, configuredStartTime, rolloutMode, numberDevices, deploymentDeviceCount, filter]); + }, + [getValues, setValue, configuredStartTime, rolloutMode, numberDevices, deploymentDeviceCount, filter] + ); const handleModeChange = ({ target: { value } }: React.ChangeEvent) => setValue(deploymentFormSections.rolloutMode, value); @@ -161,17 +148,20 @@ export const RolloutPatternSelection = ({ isEnterprise, previousPhases = [] }: R const { component: ActivePatternComponent } = rolloutPatterns[activePattern]; return ( <> - } + Select a rollout pattern - + } + slotProps={{ checkbox: { className: 'margin-left-small', size: 'small' } }} /> diff --git a/frontend/src/js/components/deployments/deployment-wizard/RolloutOptions.tsx b/frontend/src/js/components/deployments/deployment-wizard/RolloutOptions.tsx index f5bd81da6..98e7a8b0c 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/RolloutOptions.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/RolloutOptions.tsx @@ -18,7 +18,7 @@ import { HelpOutlineOutlined as HelpIcon } from '@mui/icons-material'; import { Alert, Checkbox, Collapse, FormControlLabel, Tooltip, Typography } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { DOCSTIPS, DocsTooltip } from '@northern.tech/common-ui/DocsLink'; +import { DOCSTIPS, DocsTextLink } from '@northern.tech/common-ui/DocsLink'; import EnterpriseNotification from '@northern.tech/common-ui/EnterpriseNotification'; import { InfoHintContainer } from '@northern.tech/common-ui/InfoHint'; import Link from '@northern.tech/common-ui/Link'; @@ -27,40 +27,23 @@ import { NumberInput } from '@northern.tech/common-ui/forms/NumberInput'; import { BENEFITS } from '@northern.tech/store/constants'; import { toggle } from '@northern.tech/utils/helpers'; -import { HELPTOOLTIPS } from '../../helptips/HelpTooltips'; -import { MenderHelpTooltip } from '../../helptips/MenderTooltip'; import RolloutSteps from './RolloutSteps'; import { deploymentFormSections } from './utils'; const useStyles = makeStyles()(() => ({ - defaultBox: { marginTop: 0, marginBottom: -15 }, - heading: { marginBottom: 0 }, - retryInput: { maxWidth: 150, minWidth: 130 }, wrapper: { minHeight: 300 } })); export const ForceDeploy = () => { const { control } = useFormContext(); - const { classes } = useStyles(); return (
- Force update (optional) - -
- } + label="Force update if the software is already installed" + slotProps={{ checkbox: { className: 'margin-left-small', size: 'small' } }} /> ); @@ -87,22 +70,20 @@ export const RolloutOptions = ({ isEnterprise }) => { return ( <> } + control={} label={
- Add pauses between update steps (optional) + Add pauses between update steps - +
} /> - - Synchronized updates have been removed in Mender Client 4.0. Configuring pause states between update steps will have no effect on deployments - targeting devices running Mender Client 4.0 or later. + + This feature was removed in Mender Client 4.0. To manage phased deployments for newer devices, we recommend using rollout patterns instead. 1 || !isEnterprise} onStepChange={onStepChangeClick} release={release} steps={states} /> @@ -112,30 +93,26 @@ export const RolloutOptions = ({ isEnterprise }) => { const maxDeploymentRetries = 100; -export const Retries = ({ canManageUsers, canRetry, commonClasses, defaultRetries }) => { - const { classes } = useStyles(); - - return ( - <> -
- - Set the number of times each device will attempt this update - - - - -
-
- - - - - {canManageUsers && ( - - Change global settings - - )} -
- - ); -}; +export const Retries = ({ canManageUsers, canRetry, commonClasses, defaultRetries }) => ( + <> +
+ + Set the number of times each device will attempt this update + + + + +
+
+ + + + + {canManageUsers && ( + + Change global default + + )} +
+ +); diff --git a/frontend/src/js/components/deployments/deployment-wizard/RolloutSteps.tsx b/frontend/src/js/components/deployments/deployment-wizard/RolloutSteps.tsx index ff42d97a9..ae91f68dc 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/RolloutSteps.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/RolloutSteps.tsx @@ -17,7 +17,7 @@ import { Add as AddIcon, ArrowRight as ArrowRightIcon, PauseCircleOutlined as Pa import { Chip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import DocsLink from '@northern.tech/common-ui/DocsLink'; +import { DOCSTIPS, DocsTextLink } from '@northern.tech/common-ui/DocsLink'; import InfoText from '@northern.tech/common-ui/InfoText'; import MenderTooltip from '@northern.tech/common-ui/helptips/MenderTooltip'; import { TIMEOUTS } from '@northern.tech/store/constants'; @@ -153,7 +153,7 @@ export const RolloutStepsContainer = ({ className = '', disabled, onStepChange, A 'pause' means each device will pause its update after completing the previous step, and wait for approval before continuing.
- You can grant approval by clicking "continue" in the deployment progress UI. + You can grant approval by clicking "continue" in the deployment progress UI.
)} diff --git a/frontend/src/js/components/deployments/deployment-wizard/ScheduleRollout.tsx b/frontend/src/js/components/deployments/deployment-wizard/ScheduleRollout.tsx index 314880f4b..8ebff830e 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/ScheduleRollout.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/ScheduleRollout.tsx @@ -20,6 +20,7 @@ import { makeStyles } from 'tss-react/mui'; import EnterpriseNotification from '@northern.tech/common-ui/EnterpriseNotification'; import { InfoHintContainer } from '@northern.tech/common-ui/InfoHint'; +import { defaultTimeFormat } from '@northern.tech/common-ui/Time'; import { BENEFITS } from '@northern.tech/store/constants'; import dayjs from 'dayjs'; @@ -78,10 +79,11 @@ export const ScheduleRollout = ({ canSchedule, commonClasses, open = false }) => onOpen={() => setIsPickerOpen(true)} onClose={() => setIsPickerOpen(false)} label="Starting at" + format={defaultTimeFormat} minDateTime={dayjs()} disabled={!canSchedule} onChange={date => handleStartTimeChange(date.toISOString())} - slotProps={{ textField: { style: { minWidth: 400 } } }} + slotProps={{ textField: { size: 'small', style: { minWidth: 400 } } }} value={dayjs(startTime)} />
diff --git a/frontend/src/js/components/deployments/deployment-wizard/SoftwareDevices.tsx b/frontend/src/js/components/deployments/deployment-wizard/SoftwareDevices.tsx index 5c558e06e..487dae531 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/SoftwareDevices.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/SoftwareDevices.tsx @@ -17,11 +17,10 @@ import { useSelector } from 'react-redux'; import { ErrorOutlined as ErrorOutlineIcon } from '@mui/icons-material'; import { ExpandLess as ExpandLessIcon, ExpandMore as ExpandMoreIcon } from '@mui/icons-material'; -import { Alert, Button, TextField, Tooltip, Typography } from '@mui/material'; +import { Alert, Button, FormHelperText, TextField, Tooltip, Typography } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; import { getDeviceIdentityText } from '@northern.tech/common-ui/DeviceIdentity'; -import InfoText from '@northern.tech/common-ui/InfoText'; import { Link } from '@northern.tech/common-ui/Link'; import { ControlledAutoComplete } from '@northern.tech/common-ui/forms/Autocomplete'; import { ALL_DEVICES, ATTRIBUTE_SCOPES, DEPLOYMENT_TYPES, DEVICE_FILTERING_OPTIONS, DEVICE_STATES } from '@northern.tech/store/constants'; @@ -46,7 +45,8 @@ const useStyles = makeStyles()(theme => ({ minWidth: 400, borderBottom: 'none' }, - selection: { minWidth: 'min-content', maxWidth: theme.spacing(50), minHeight: 96 }, + selection: { minWidth: 'min-content', maxWidth: theme.spacing(50) }, + textField: { minWidth: 400 }, releaseSelect: { maxWidth: '400px', minWidth: '235px' }, releaseSelectText: { minWidth: 0, flexGrow: 1 } })); @@ -116,13 +116,13 @@ export const getDeploymentTargetText = ({ deployment, devicesById, idAttribute } }; export const ReleasesWarning = ({ lacksReleases }) => ( -
- - + + + There are no {lacksReleases ? 'compatible ' : ''}artifacts available.{lacksReleases ?
: ' '} Upload one to the repository to get started. -
-
+ + ); export const Devices = ({ devicesById, groupRef, groupNames, hasDevices, hasDynamicGroups, hasPending, idAttribute, initialDevices = [] }) => { @@ -145,12 +145,7 @@ export const Devices = ({ devicesById, groupRef, groupNames, hasDevices, hasDyna targetDeviceCount = 1; } else if (group) { deviceText = ''; - targetDevicesText = 'All devices'; - targetDeviceCount = 2; - if (group !== ALL_DEVICES) { - targetDevicesText = `${targetDevicesText} in this group`; - targetDeviceCount = deploymentDeviceCount; - } + targetDevicesText = `Estimate ${targetDevicesText}`; } return { deviceText, devicesLink, targetDeviceCount, targetDevicesText }; }, [devices, filter, group, devicesById, idAttribute, deploymentDeviceCount, device]); @@ -160,7 +155,7 @@ export const Devices = ({ devicesById, groupRef, groupNames, hasDevices, hasDyna Select a device group to target -
+
{deviceText ? ( ) : ( @@ -177,26 +172,28 @@ export const Devices = ({ devicesById, groupRef, groupNames, hasDevices, hasDyna renderInput={params => } /> {!(hasDevices || hasDynamicGroups) && ( - - - There are no connected devices.{' '} - {hasPending ? ( - - Accept pending devices to get started. - - ) : ( - - Read the help pages for help with connecting devices. - - )} - + + + + There are no connected devices.{' '} + {hasPending ? ( + <> + Accept pending devices to get started. + + ) : ( + <> + Read the help pages for help with connecting devices. + + )} + + )}
)} {!!targetDeviceCount && ( - + {targetDevicesText} will be targeted. View the {pluralize('devices', targetDeviceCount)} - + )}
@@ -253,36 +250,33 @@ export const Software = ({ commonClasses, releaseRef, releaseSelectionLocked, re Select software to deploy -
-
- {releaseSelectionLocked ? ( - - ) : ( - <> - setReleaseFilterOpened(false)} - /> - - - )} - {!releaseItems.length ? ( - - ) : ( - !!compatibleTypes.length && This software is compatible with {devicetypesInfo}. - )} -
+
+ {releaseSelectionLocked ? ( + + ) : ( + <> + setReleaseFilterOpened(false)} + /> + + + )} + {!releaseItems.length ? ( + + ) : ( + !!compatibleTypes.length && This software is compatible with {devicetypesInfo}. + )}
{showSizeWarning && (
diff --git a/frontend/src/js/components/helptips/HelpTooltips.tsx b/frontend/src/js/components/helptips/HelpTooltips.tsx index cd294db61..d5759aeb0 100644 --- a/frontend/src/js/components/helptips/HelpTooltips.tsx +++ b/frontend/src/js/components/helptips/HelpTooltips.tsx @@ -179,13 +179,6 @@ const GroupDeployment = () => ( <>The deployment will skip any devices in the group that are already on the target Release version, or that have an incompatible device type. ); -const ForceDeployment = () => ( - <> -

Force update

-

This will make the Mender client install the update even if the selected release is already installed.

- -); - const ArtifactUpload = () => ( <> Upload a premade Mender Artifact or create one from a single file. @@ -337,7 +330,6 @@ export const HELPTOOLTIPS: Record = { deviceSupportTip: { id: 'deviceSupportTip', Component: DeviceSupportTip }, deviceTypeTip: { id: 'deviceTypeTip', Component: DeviceTypeTip }, expandArtifact: { id: 'expandArtifact', Component: ExpandArtifact }, - forceDeployment: { id: 'forceDeployment', Component: ForceDeployment }, groupDeployment: { id: 'groupDeployment', Component: GroupDeployment }, microDevice: { id: 'microDevice', Component: MCUDevice }, manifestUpload: { id: 'manifestUpload', Component: ManifestUpload }, From b4845e300fec8faee4579c55924a86c6d80c8d12 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Tue, 4 Aug 2026 23:53:20 +0200 Subject: [PATCH 16/20] refactor(gui): integrated deployment options further into rhf - this handles rollout pattern, pause settings & device limit expansion explicitly Signed-off-by: Manuel Zedel --- .../deployments/CreateDeployment.test.tsx | 4 ++-- .../deployments/CreateDeployment.tsx | 17 +++++++++++++---- .../deployment-wizard/DeviceLimit.tsx | 8 ++++---- .../deployment-wizard/PhaseSettings.tsx | 19 +++++-------------- .../deployment-wizard/RolloutOptions.tsx | 14 ++++++-------- .../deployment-wizard/ScheduleRollout.tsx | 4 +++- .../deployment-wizard/phases/CustomPhases.tsx | 13 ++++++++----- .../deployments/deployment-wizard/types.ts | 4 ++++ .../deployments/deployment-wizard/utils.tsx | 5 ++++- 9 files changed, 49 insertions(+), 39 deletions(-) diff --git a/frontend/src/js/components/deployments/CreateDeployment.test.tsx b/frontend/src/js/components/deployments/CreateDeployment.test.tsx index ab334beff..9ab2c2cbb 100644 --- a/frontend/src/js/components/deployments/CreateDeployment.test.tsx +++ b/frontend/src/js/components/deployments/CreateDeployment.test.tsx @@ -76,7 +76,7 @@ describe('CreateDeployment Component', () => { it(`renders ${Component.displayName || Component.name} correctly`, () => { const { baseElement } = render( - + , @@ -90,7 +90,7 @@ describe('CreateDeployment Component', () => { it(`renders ${Component.displayName || Component.name} correctly as enterprise`, () => { const { baseElement } = render( - + , diff --git a/frontend/src/js/components/deployments/CreateDeployment.tsx b/frontend/src/js/components/deployments/CreateDeployment.tsx index c42de8348..7d84de34b 100644 --- a/frontend/src/js/components/deployments/CreateDeployment.tsx +++ b/frontend/src/js/components/deployments/CreateDeployment.tsx @@ -101,12 +101,15 @@ export const defaultValues: DeploymentFormValues = { release: null, delta: false, forceDeploy: false, + isPaused: false, maxDevices: 0, retries: 1, phases: [], rolloutMode: rolloutModes.percentage.key, + startTime: undefined, uniform_phases: undefined, - update_control_map: { states: {} } + update_control_map: { states: {} }, + usesPattern: false }; export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleSubmit, onValuesChange, open }) => { @@ -163,18 +166,24 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS const inferredMode = deploymentObject.rolloutMode ?? (deploymentObject.phases?.some(({ batch_size_devices }) => batch_size_devices !== null) ? rolloutModes.device_count.key : rolloutModes.percentage.key); + const initialPhases = deploymentObject.phases ?? defaultValues.phases; + // a single full-size phase only carries a start time (as created by e.g. a plain scheduled deployment or a + // retry), it takes more than one phase to make a rollout pattern - which also keeps pauses & pattern exclusive + const hasPhasePattern = initialPhases.length > 1; reset({ group: deploymentObject.group ?? defaultValues.group, release: deploymentObject.release ?? defaultValues.release, delta: deploymentObject.delta ?? defaultValues.delta, forceDeploy: deploymentObject.forceDeploy ?? defaultValues.forceDeploy, + isPaused: !hasPhasePattern && !isEmpty(deploymentObject.update_control_map?.states ?? {}), maxDevices: deploymentObject.maxDevices ?? defaultValues.maxDevices, retries: (deploymentObject.retries ?? previousRetries ?? 0) + 1, - phases: deploymentObject.phases ?? defaultValues.phases, + phases: initialPhases, rolloutMode: inferredMode, - startTime: deploymentObject.startTime ?? defaultValues.startTime, + startTime: deploymentObject.startTime ?? deploymentObject.phases?.[0]?.start_ts ?? defaultValues.startTime, uniform_phases: deploymentObject.uniform_phases ?? defaultValues.uniform_phases, - update_control_map: deploymentObject.update_control_map ?? defaultValues.update_control_map + update_control_map: deploymentObject.update_control_map ?? defaultValues.update_control_map, + usesPattern: hasPhasePattern }); } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/frontend/src/js/components/deployments/deployment-wizard/DeviceLimit.tsx b/frontend/src/js/components/deployments/deployment-wizard/DeviceLimit.tsx index ad4d08bad..751e5fb3a 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/DeviceLimit.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/DeviceLimit.tsx @@ -11,7 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useFormContext } from 'react-hook-form'; import { Checkbox, Collapse, FormControlLabel, FormHelperText } from '@mui/material'; @@ -27,17 +27,17 @@ export const DeviceLimit = () => { const { setValue, watch } = useFormContext(); const { deploymentDeviceCount, deploymentDeviceIds, filter } = useDerivedData(watch); const numberDevices = deploymentDeviceCount ? deploymentDeviceCount : deploymentDeviceIds ? deploymentDeviceIds.length : 0; - const [shouldLimit, setShouldLimit] = useState(false); + const shouldLimit = watch(deploymentFormSections.shouldLimit); useEffect(() => { if (!filter) { setValue(deploymentFormSections.maxDevices, 0); - setShouldLimit(false); + setValue(deploymentFormSections.shouldLimit, false); } }, [filter, setValue]); const onToggleLimit = (_, checked) => { - setShouldLimit(checked); + setValue(deploymentFormSections.shouldLimit, checked); if (checked) { setValue(deploymentFormSections.maxDevices, numberDevices); } else { diff --git a/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx b/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx index 734189b3d..4a7122291 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx @@ -11,7 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { useFormContext } from 'react-hook-form'; import { Alert, Collapse, FormControl, FormControlLabel, ListSubheader, MenuItem, Radio, RadioGroup, Select, Tooltip, Typography } from '@mui/material'; @@ -26,16 +26,8 @@ import { isDarkMode } from '@northern.tech/store/utils'; import type { Filter } from '@northern.tech/types/MenderTypes'; import { CustomPhaseTable } from './phases/CustomPhases'; -import type { RolloutPattern } from './phases/constants'; -import { - type RolloutMode, - delayDefaults, - delayUnits, - phaseDefaults, - phaseLimits, - rolloutModes, - rolloutPatterns as rolloutPatternDefinitions -} from './phases/constants'; +import type { RolloutMode, RolloutPattern } from './phases/constants'; +import { delayDefaults, delayUnits, phaseDefaults, phaseLimits, rolloutModes, rolloutPatterns as rolloutPatternDefinitions } from './phases/constants'; import { getPhasesMessage, toPhaseDescription } from './phases/utils'; import type { DeploymentFormValues } from './types'; import { deploymentFormSections, useDerivedData } from './utils'; @@ -81,11 +73,10 @@ export const RolloutPatternSelection = ({ isEnterprise, previousPhases = [] }: R const { deploymentDeviceCount, deploymentDeviceIds, filter } = useDerivedData(watch); const phases = watch(deploymentFormSections.phases) || []; const rolloutMode: RolloutMode = watch(deploymentFormSections.rolloutMode) || rolloutModes.percentage.key; + const usesPattern = watch(deploymentFormSections.usesPattern); const configuredStartTime = watch(deploymentFormSections.startTime); const maxDevices = watch(deploymentFormSections.maxDevices); const group = watch(deploymentFormSections.group); - - const [usesPattern, setUsesPattern] = useState(phases.some(i => i)); const { classes } = useStyles(); const numberDevices = deploymentDeviceCount ? deploymentDeviceCount : deploymentDeviceIds ? deploymentDeviceIds.length : 0; @@ -163,7 +154,7 @@ export const RolloutPatternSelection = ({ isEnterprise, previousPhases = [] }: R } slotProps={{ checkbox: { className: 'margin-left-small', size: 'small' } }} /> - + + + + {!!errors.startTime && {errors.startTime.message}} diff --git a/frontend/src/js/components/deployments/deployment-wizard/SoftwareDevices.tsx b/frontend/src/js/components/deployments/deployment-wizard/SoftwareDevices.tsx index 487dae531..a95ca7664 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/SoftwareDevices.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/SoftwareDevices.tsx @@ -36,7 +36,7 @@ import { HELPTOOLTIPS } from '../../helptips/HelpTooltips'; import { MenderHelpTooltip } from '../../helptips/MenderTooltip'; import { SoftwareArtifactFilter } from './ReleaseArtifactFilter'; import type { DeploymentFormValues } from './types'; -import { deploymentFormSections, useDerivedData } from './utils'; +import { deploymentFormSections, useDerivedData, useValidatedSetValue } from './utils'; const { isUUID } = validator; @@ -129,7 +129,10 @@ export const Devices = ({ devicesById, groupRef, groupNames, hasDevices, hasDyna const { classes } = useStyles(); // eslint-disable-next-line @typescript-eslint/no-unused-vars const size = useWindowSize(); - const { watch } = useFormContext(); + const { + formState: { errors }, + watch + } = useFormContext(); const group = watch(deploymentFormSections.group); const { deploymentDeviceCount, devices, filter } = useDerivedData(watch, initialDevices); @@ -169,7 +172,15 @@ export const Devices = ({ devicesById, groupRef, groupNames, hasDevices, hasDyna handleHomeEndKeys disabled={!(hasDevices || hasDynamicGroups)} options={groupNames} - renderInput={params => } + renderInput={params => ( + + )} /> {!(hasDevices || hasDynamicGroups) && ( @@ -208,7 +219,11 @@ export const Software = ({ commonClasses, releaseRef, releaseSelectionLocked, re const deviceLimits = useSelector(getDeviceLimits); const dispatch = useAppDispatch(); const { classes } = useStyles(); - const { watch, setValue } = useFormContext(); + const { + formState: { errors }, + watch + } = useFormContext(); + const setValue = useValidatedSetValue(); const deploymentRelease = watch(deploymentFormSections.release); // resolve the full Release from releasesById to get artifacts/device_types_compatible @@ -263,6 +278,7 @@ export const Software = ({ commonClasses, releaseRef, releaseSelectionLocked, re /> + {!!errors.release && {errors.release.message}} )} {!releaseItems.length ? ( diff --git a/frontend/src/js/components/deployments/deployment-wizard/phases/CustomPhases.tsx b/frontend/src/js/components/deployments/deployment-wizard/phases/CustomPhases.tsx index 16bc4325a..590f6f03f 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/phases/CustomPhases.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/phases/CustomPhases.tsx @@ -15,13 +15,13 @@ import { useEffect, useRef } from 'react'; import { useFormContext } from 'react-hook-form'; import { Add as AddIcon, Close as CancelIcon, RepeatOutlined as RepeatIcon } from '@mui/icons-material'; -import { Button, IconButton, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; +import { Button, FormHelperText, IconButton, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; import Time from '@northern.tech/common-ui/Time'; import type { Filter } from '@northern.tech/types/MenderTypes'; import type { DeploymentFormValues } from '../types'; -import { deploymentFormSections, getPhaseStartTime } from '../utils'; +import { deploymentFormSections, getPhaseStartTime, useValidatedSetValue } from '../utils'; import { BatchSizeInput, DelayInput } from './Input'; import type { RolloutMode } from './constants'; import { delayDefaults, delayUnits, phaseDefaults, rolloutModes } from './constants'; @@ -57,7 +57,12 @@ const evenSplitThreshold = 50; const tableHeaders = ['Phases', 'Batch size', 'Phase begins', 'Delay before next phase', '']; export const CustomPhaseTable = ({ filter, deploymentDeviceCount }: { deploymentDeviceCount: number; filter?: Filter }) => { - const { watch, setValue, getValues } = useFormContext(); + const { + formState: { errors }, + watch, + getValues + } = useFormContext(); + const setValue = useValidatedSetValue(); const phases: Array = watch(deploymentFormSections.phases) || []; const rolloutMode: RolloutMode = watch(deploymentFormSections.rolloutMode) || rolloutModes.percentage.key; @@ -226,6 +231,7 @@ export const CustomPhaseTable = ({ filter, deploymentDeviceCount }: { deployment + {!!errors.phases && {errors.phases.message}} ); }; diff --git a/frontend/src/js/components/deployments/deployment-wizard/utils.tsx b/frontend/src/js/components/deployments/deployment-wizard/utils.tsx index 298ee8cca..27a683bd2 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/utils.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/utils.tsx @@ -11,8 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import type { UseFormWatch } from 'react-hook-form'; +import { useFormContext } from 'react-hook-form'; import { ALL_DEVICES } from '@northern.tech/store/constants'; import { getDeviceCountsByStatus, getDevicesById, getGroupData } from '@northern.tech/store/selectors'; @@ -85,6 +86,16 @@ export const buildPhasePayload = ({ return { phases: undefined, uniform_phases: undefined }; }; +// most of the form is written through setValue, which doesn't re-run the validation unless it is told to - and it has +// to, so that an error the user just resolved goes away right away instead of lingering until the next submit attempt +export const useValidatedSetValue = () => { + const { + formState: { isSubmitted }, + setValue + } = useFormContext(); + return useCallback((name, value) => setValue(name, value, { shouldValidate: isSubmitted }), [isSubmitted, setValue]); +}; + export type DeploymentDerivedState = { deploymentDeviceCount: number; deploymentDeviceIds: string[]; diff --git a/frontend/src/js/components/deployments/deployment-wizard/validation.test.ts b/frontend/src/js/components/deployments/deployment-wizard/validation.test.ts new file mode 100644 index 000000000..5e1b6758b --- /dev/null +++ b/frontend/src/js/components/deployments/deployment-wizard/validation.test.ts @@ -0,0 +1,52 @@ +// Copyright 2026 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { defaultState } from '@/testUtils'; + +import { defaultValues } from '../CreateDeployment'; +import { deploymentErrors, deploymentResolver } from './validation'; + +const deploymentCreationTime = defaultState.deployments.byId.d1.created; + +describe('deploymentResolver function', () => { + const release = { name: 'test-release' }; + const context = { deploymentDeviceCount: 5, devices: [] }; + + it('requires a target & software', async () => { + const { errors, values } = deploymentResolver(defaultValues, { deploymentDeviceCount: 0, devices: [] }); + expect(errors.group?.message).toEqual(deploymentErrors.group); + expect(errors.release?.message).toEqual(deploymentErrors.release); + expect(values).toEqual({}); + }); + it('accepts preselected devices in place of a group', async () => { + const { errors } = deploymentResolver({ ...defaultValues, release }, { deploymentDeviceCount: 1, devices: [defaultState.devices.byId.a1] }); + expect(errors).toEqual({}); + }); + it('passes a fully specified deployment', async () => { + const { errors, values } = deploymentResolver({ ...defaultValues, group: 'testGroup', release }, context); + expect(errors).toEqual({}); + expect(values).toEqual({ ...defaultValues, group: 'testGroup', release }); + }); + it('rejects scheduling a deployment for an empty group', async () => { + const values = { ...defaultValues, group: 'testGroup', release, startTime: deploymentCreationTime }; + expect(deploymentResolver(values, { deploymentDeviceCount: 0, devices: [], group: 'testGroup' }).errors.startTime?.message).toEqual( + deploymentErrors.emptyGroupSchedule + ); + expect(deploymentResolver(values, context).errors.startTime).toBeFalsy(); + }); + it('requires a device count once the deployment is limited', async () => { + const values = { ...defaultValues, group: 'testGroup', release, shouldLimit: true }; + expect(deploymentResolver(values, context).errors.maxDevices?.message).toEqual(deploymentErrors.maxDevices); + expect(deploymentResolver({ ...values, maxDevices: 2 }, context).errors.maxDevices).toBeFalsy(); + }); +}); diff --git a/frontend/src/js/components/deployments/deployment-wizard/validation.ts b/frontend/src/js/components/deployments/deployment-wizard/validation.ts new file mode 100644 index 000000000..68fda0419 --- /dev/null +++ b/frontend/src/js/components/deployments/deployment-wizard/validation.ts @@ -0,0 +1,49 @@ +// Copyright 2026 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import type { Resolver } from 'react-hook-form'; + +import { isEmpty } from '@northern.tech/utils/helpers'; + +import type { DeploymentFormValues } from './types'; +import type { DeploymentDerivedState } from './utils'; + +export const deploymentErrors = { + emptyGroupSchedule: 'Cannot schedule deployment for an empty device group. Please select a different start time or choose a group with devices', + group: 'Please select a device group to target', + maxDevices: 'Number of devices is required', + phases: 'Each phase has to contain at least 1 device and the phases may not exceed 100% in total', + release: 'Please select software to deploy' +}; + +export type DeploymentResolverContext = Pick & { group: string | null }; + +// the target device count & the preselected devices live outside of the form, so the validation has to run as a +// resolver with them handed in as context instead of as rules on the individual fields +export const deploymentResolver: Resolver = (values, context) => { + const { deploymentDeviceCount = 0, devices = [], filter, group } = context ?? {}; + const errors: Record = {}; + if (!values.group && !devices.length) { + errors.group = { message: deploymentErrors.group, type: 'required' }; + } + if (!values.release) { + errors.release = { message: deploymentErrors.release, type: 'required' }; + } + if (values.startTime && !deploymentDeviceCount && group && !filter) { + errors.startTime = { message: deploymentErrors.emptyGroupSchedule, type: 'validate' }; + } + if (values.shouldLimit && !(Number(values.maxDevices) >= 1)) { + errors.maxDevices = { message: deploymentErrors.maxDevices, type: 'required' }; + } + return { errors, values: isEmpty(errors) ? values : {} }; +}; From 16db4975f9bf775ea3309f298983547e5d1f2dc9 Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Thu, 6 Aug 2026 15:56:30 +0200 Subject: [PATCH 18/20] feat(gui): explained & cleared unavailable deployment options Ticket: MEN-9322 Signed-off-by: Manuel Zedel --- .../deployments/CreateDeployment.test.tsx | 19 +++++++- .../deployments/CreateDeployment.tsx | 37 ++++++++++---- .../deployment-wizard/DeviceLimit.tsx | 19 +++----- .../deployment-wizard/PhaseSettings.tsx | 8 ++-- .../deployment-wizard/RolloutOptions.tsx | 7 +-- .../deployments/deployment-wizard/utils.tsx | 25 +++++++++- .../deployment-wizard/validation.test.ts | 40 +++++++++++++++- .../deployment-wizard/validation.ts | 48 +++++++++++++++++++ 8 files changed, 172 insertions(+), 31 deletions(-) diff --git a/frontend/src/js/components/deployments/CreateDeployment.test.tsx b/frontend/src/js/components/deployments/CreateDeployment.test.tsx index 9159e1b1a..31b4d736d 100644 --- a/frontend/src/js/components/deployments/CreateDeployment.test.tsx +++ b/frontend/src/js/components/deployments/CreateDeployment.test.tsx @@ -42,6 +42,9 @@ const FormWrapper = ({ children, defaultValues = {} }) => { return {children}; }; +// the rollout options exclude each other, so they have to be enabled one at a time to render their expanded state +const expandedDefaultValues = { RolloutOptions: { isPaused: true }, RolloutPatternSelection: { usesPattern: true } }; + const preloadedState = { ...defaultState, app: { @@ -81,7 +84,7 @@ describe('CreateDeployment Component', () => { it(`renders ${Component.displayName || Component.name} correctly`, () => { const { baseElement } = render( - + , @@ -95,7 +98,7 @@ describe('CreateDeployment Component', () => { it(`renders ${Component.displayName || Component.name} correctly as enterprise`, () => { const { baseElement } = render( - + , @@ -134,6 +137,18 @@ describe('CreateDeployment Component', () => { expect(screen.getByText(deploymentErrors.release)).toBeVisible(); }); + it('drops an error once the option that caused it is switched off again', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(, { preloadedState }); + await user.click(screen.getByRole('button', { name: /advanced options/i })); + const limitCheckbox = screen.getByRole('checkbox', { name: /maximum number of devices/i }); + await user.click(limitCheckbox); + await user.click(screen.getByRole('button', { name: /create deployment/i })); + await waitFor(() => expect(screen.getByText(deploymentErrors.maxDevices)).toBeVisible()); + await user.click(limitCheckbox); + await waitFor(() => expect(screen.queryByText(deploymentErrors.maxDevices)).toBeFalsy()); + }); + it('applies a schedule even without a rollout pattern', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); // scheduling is plan gated, so the plain preloadedState would leave the schedule selection disabled diff --git a/frontend/src/js/components/deployments/CreateDeployment.tsx b/frontend/src/js/components/deployments/CreateDeployment.tsx index 59630d10b..956891720 100644 --- a/frontend/src/js/components/deployments/CreateDeployment.tsx +++ b/frontend/src/js/components/deployments/CreateDeployment.tsx @@ -66,7 +66,7 @@ import { rolloutModes } from './deployment-wizard/phases/constants'; import type { DeploymentFormValues } from './deployment-wizard/types'; import { buildPhasePayload, deploymentFormSections, useDerivedData } from './deployment-wizard/utils'; import type { DeploymentResolverContext } from './deployment-wizard/validation'; -import { deploymentResolver } from './deployment-wizard/validation'; +import { deploymentResolver, getDeviceLimitDisabledReason, getPausesDisabledReason, getRolloutPatternDisabledReason } from './deployment-wizard/validation'; const useStyles = makeStyles()(theme => ({ accordion: { @@ -166,13 +166,18 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS watch } = methods; const formValues = watch(); - const { group, release } = formValues; - const { deploymentDeviceCount, deploymentDeviceIds, devices, filter } = useDerivedData(watch, deploymentObject.devices); + const { group, isPaused, release, shouldLimit, usesPattern } = formValues; + const { deploymentDeviceCount, deploymentDeviceIds, devices, filter, isDeviceCountResolved } = useDerivedData(watch, deploymentObject.devices); validationContext.current.deploymentDeviceCount = deploymentDeviceCount; validationContext.current.devices = devices; validationContext.current.filter = filter; validationContext.current.group = group; + const target = { deploymentDeviceCount, devices, filter, group, isDeviceCountResolved }; + const deviceLimitDisabledReason = getDeviceLimitDisabledReason(target); + const rolloutPatternDisabledReason = getRolloutPatternDisabledReason({ ...target, isPaused }); + const pausesDisabledReason = getPausesDisabledReason({ ...target, usesPattern }); + useEffect(() => { dispatch(getReleases({ page: 1, perPage: 100, searchOnly: true, searchTerm: '', selectedTags: [], type: '' })); }, [dispatch]); @@ -212,6 +217,22 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, reset]); + // options the selected target has ruled out have to be dropped - they could otherwise neither apply nor be removed, + // with their controls disabled while the validation keeps rejecting them + useEffect(() => { + if (deviceLimitDisabledReason && shouldLimit) { + setValue(deploymentFormSections.shouldLimit, false, { shouldValidate: isSubmitted }); + setValue(deploymentFormSections.maxDevices, 0, { shouldValidate: isSubmitted }); + } + if (rolloutPatternDisabledReason && usesPattern) { + setValue(deploymentFormSections.usesPattern, false, { shouldValidate: isSubmitted }); + setValue(deploymentFormSections.phases, [], { shouldValidate: isSubmitted }); + } + if (pausesDisabledReason && isPaused) { + setValue(deploymentFormSections.isPaused, false, { shouldValidate: isSubmitted }); + } + }, [deviceLimitDisabledReason, isPaused, isSubmitted, pausesDisabledReason, rolloutPatternDisabledReason, setValue, shouldLimit, usesPattern]); + // the target device count is not part of the form, so a change there has to re-run the validation by hand useEffect(() => { if (isSubmitted) { @@ -275,7 +296,7 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS if (needsCheck && !isChecking) { return setIsChecking(true); } - const { delta, forceDeploy = false, maxDevices, phases, release, rolloutMode, startTime, uniform_phases, update_control_map } = formValues; + const { delta, forceDeploy = false, isPaused, maxDevices, phases, release, rolloutMode, startTime, uniform_phases, update_control_map } = formValues; const retries = (formValues.retries ?? 1) - 1; const retrySetting = canRetry && retries ? { retries } : {}; const phasePayload = buildPhasePayload({ phases, rolloutMode, startTime, uniform_phases }); @@ -291,7 +312,7 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS ...phasePayload, ...retrySetting, force_installation: forceDeploy, - update_control_map: !isEmpty(update_control_map.states) ? update_control_map : undefined + update_control_map: isPaused && !isEmpty(update_control_map.states) ? update_control_map : undefined }; if (!isOnboardingComplete) { dispatch(advanceOnboarding(onboardingSteps.SCHEDULING_RELEASE_TO_DEVICES)); @@ -359,9 +380,9 @@ export const CreateDeployment = ({ deploymentObject = {}, onDismiss, onScheduleS - - - + + + {!isTrial && hasDeltaEnabled && ( { +export const DeviceLimit = ({ disabledReason = '' }) => { const { watch } = useFormContext(); const setValue = useValidatedSetValue(); - const { deploymentDeviceCount, deploymentDeviceIds, filter } = useDerivedData(watch); + const { deploymentDeviceCount, deploymentDeviceIds } = useDerivedData(watch); const numberDevices = deploymentDeviceCount ? deploymentDeviceCount : deploymentDeviceIds ? deploymentDeviceIds.length : 0; const shouldLimit = watch(deploymentFormSections.shouldLimit); - useEffect(() => { - if (!filter) { - setValue(deploymentFormSections.maxDevices, 0); - setValue(deploymentFormSections.shouldLimit, false); - } - }, [filter, setValue]); - const onToggleLimit = (_, checked) => { setValue(deploymentFormSections.shouldLimit, checked); if (checked) { @@ -49,11 +41,14 @@ export const DeviceLimit = () => { return ( <> } + control={ + + } label={
Limit deployment to a maximum number of devices +
diff --git a/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx b/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx index eefb7fdfa..00cbc32b2 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/PhaseSettings.tsx @@ -30,7 +30,7 @@ import type { RolloutMode, RolloutPattern } from './phases/constants'; import { delayDefaults, delayUnits, phaseDefaults, phaseLimits, rolloutModes, rolloutPatterns as rolloutPatternDefinitions } from './phases/constants'; import { getPhasesMessage, toPhaseDescription } from './phases/utils'; import type { DeploymentFormValues } from './types'; -import { deploymentFormSections, useDerivedData, useValidatedSetValue } from './utils'; +import { DisabledReasonHint, deploymentFormSections, useDerivedData, useValidatedSetValue } from './utils'; const useStyles = makeStyles()(theme => ({ container: { @@ -64,11 +64,12 @@ const getDefaultPhasesForPattern = ( }; interface RolloutPatternSelectionProps { + disabledReason: string; isEnterprise: boolean; previousPhases?: Array>>; } -export const RolloutPatternSelection = ({ isEnterprise, previousPhases = [] }: RolloutPatternSelectionProps) => { +export const RolloutPatternSelection = ({ isEnterprise, disabledReason = '', previousPhases = [] }: RolloutPatternSelectionProps) => { const { watch, getValues } = useFormContext(); const setValue = useValidatedSetValue(); const { deploymentDeviceCount, deploymentDeviceIds, filter } = useDerivedData(watch); @@ -142,13 +143,14 @@ export const RolloutPatternSelection = ({ isEnterprise, previousPhases = [] }: R <> Select a rollout pattern + {isEnterprise && }
diff --git a/frontend/src/js/components/deployments/deployment-wizard/RolloutOptions.tsx b/frontend/src/js/components/deployments/deployment-wizard/RolloutOptions.tsx index 951d0fc0a..c4b9548d4 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/RolloutOptions.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/RolloutOptions.tsx @@ -26,7 +26,7 @@ import { NumberInput } from '@northern.tech/common-ui/forms/NumberInput'; import { BENEFITS } from '@northern.tech/store/constants'; import RolloutSteps from './RolloutSteps'; -import { deploymentFormSections, useValidatedSetValue } from './utils'; +import { DisabledReasonHint, deploymentFormSections, useValidatedSetValue } from './utils'; const useStyles = makeStyles()(() => ({ wrapper: { minHeight: 300 } @@ -47,7 +47,7 @@ export const ForceDeploy = () => { ); }; -export const RolloutOptions = ({ isEnterprise }) => { +export const RolloutOptions = ({ disabledReason = '', isEnterprise }) => { const { classes } = useStyles(); const { watch } = useFormContext(); const setValue = useValidatedSetValue(); @@ -68,12 +68,13 @@ export const RolloutOptions = ({ isEnterprise }) => { <> Add pauses between update steps + {isEnterprise && }
diff --git a/frontend/src/js/components/deployments/deployment-wizard/utils.tsx b/frontend/src/js/components/deployments/deployment-wizard/utils.tsx index 27a683bd2..de02ac8ae 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/utils.tsx +++ b/frontend/src/js/components/deployments/deployment-wizard/utils.tsx @@ -15,6 +15,9 @@ import { useCallback, useEffect, useState } from 'react'; import type { UseFormWatch } from 'react-hook-form'; import { useFormContext } from 'react-hook-form'; +import { HelpOutlineOutlined as HelpIcon } from '@mui/icons-material'; +import { Tooltip } from '@mui/material'; + import { ALL_DEVICES } from '@northern.tech/store/constants'; import { getDeviceCountsByStatus, getDevicesById, getGroupData } from '@northern.tech/store/selectors'; import { useAppDispatch, useAppSelector } from '@northern.tech/store/store'; @@ -101,6 +104,7 @@ export type DeploymentDerivedState = { deploymentDeviceIds: string[]; devices: Device[]; filter: Filter | undefined; + isDeviceCountResolved: boolean; }; export const useDerivedData = (watch: UseFormWatch, initialDevices: Device[] = []): DeploymentDerivedState => { @@ -115,21 +119,29 @@ export const useDerivedData = (watch: UseFormWatch, initia const [deploymentDeviceCount, setDeploymentDeviceCount] = useState(initialDevices.length); const [deploymentDeviceIds, setDeploymentDeviceIds] = useState(initialDevices.map(({ id }) => id)); const [devices, setDevices] = useState(initialDevices); + const [isDeviceCountResolved, setIsDeviceCountResolved] = useState(!!initialDevices.length); // Compute device count from group selection useEffect(() => { if (group === ALL_DEVICES) { setDeploymentDeviceCount(acceptedDeviceCount); + setIsDeviceCountResolved(true); } else if (groups[group]) { + setIsDeviceCountResolved(false); dispatch(getGroupDevices({ group, perPage: 1 })) .unwrap() .then(result => { const total = result?.payload?.group?.total ?? 0; setDeploymentDeviceCount(total); + setIsDeviceCountResolved(true); }) - .catch(() => setDeploymentDeviceCount(0)); + .catch(() => { + setDeploymentDeviceCount(0); + setIsDeviceCountResolved(true); + }); } else if (!initialDevices.length) { setDeploymentDeviceCount(0); + setIsDeviceCountResolved(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [acceptedDeviceCount, group, dispatch, JSON.stringify(groups)]); @@ -144,6 +156,7 @@ export const useDerivedData = (watch: UseFormWatch, initia setDeploymentDeviceIds(deviceIds); setDeploymentDeviceCount(deviceIds.length); setDevices(enrichedDevices); + setIsDeviceCountResolved(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(initialDevices), JSON.stringify(devicesById)]); @@ -151,6 +164,14 @@ export const useDerivedData = (watch: UseFormWatch, initia deploymentDeviceCount, deploymentDeviceIds, devices, - filter + filter, + isDeviceCountResolved }; }; + +export const DisabledReasonHint = ({ reason }: { reason?: string }) => + reason ? ( + + + + ) : null; diff --git a/frontend/src/js/components/deployments/deployment-wizard/validation.test.ts b/frontend/src/js/components/deployments/deployment-wizard/validation.test.ts index 5e1b6758b..c800a5dfa 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/validation.test.ts +++ b/frontend/src/js/components/deployments/deployment-wizard/validation.test.ts @@ -14,9 +14,47 @@ import { defaultState } from '@/testUtils'; import { defaultValues } from '../CreateDeployment'; -import { deploymentErrors, deploymentResolver } from './validation'; +import { + deploymentErrors, + deploymentResolver, + disabledReasons, + getDeviceLimitDisabledReason, + getPausesDisabledReason, + getRolloutPatternDisabledReason +} from './validation'; const deploymentCreationTime = defaultState.deployments.byId.d1.created; +const filter = { id: 'filterId', name: 'testGroupDynamic' }; + +describe('disabled reasons', () => { + it('keeps the options available until a target rules them out', async () => { + expect(getDeviceLimitDisabledReason({ deploymentDeviceCount: 0, filter: undefined, group: null })).toEqual(''); + expect(getRolloutPatternDisabledReason({ deploymentDeviceCount: 0, filter: undefined, group: null })).toEqual(''); + expect(getPausesDisabledReason({ deploymentDeviceCount: 0, filter: undefined, group: null })).toEqual(''); + }); + it('rules out limiting the device count for static groups & direct device targets', async () => { + expect(getDeviceLimitDisabledReason({ deploymentDeviceCount: 5, filter: undefined, group: 'testGroup' })).toEqual(disabledReasons.staticGroupLimit); + expect(getDeviceLimitDisabledReason({ deploymentDeviceCount: 5, filter, group: 'testGroupDynamic' })).toEqual(''); + expect(getDeviceLimitDisabledReason({ deploymentDeviceCount: 1, devices: [defaultState.devices.byId.a1], filter: undefined, group: null })).toEqual( + disabledReasons.deviceTargetLimit + ); + }); + it('rules out the rollout options for empty groups', async () => { + const emptyGroup = { deploymentDeviceCount: 0, filter, group: 'testGroupDynamic', isDeviceCountResolved: true }; + expect(getRolloutPatternDisabledReason(emptyGroup)).toEqual(disabledReasons.emptyGroupPattern); + expect(getPausesDisabledReason(emptyGroup)).toEqual(disabledReasons.emptyGroupPauses); + }); + it('gives a group the benefit of the doubt while its device count is being retrieved', async () => { + const loadingGroup = { deploymentDeviceCount: 0, filter, group: 'testGroupDynamic', isDeviceCountResolved: false }; + expect(getRolloutPatternDisabledReason(loadingGroup)).toEqual(''); + expect(getPausesDisabledReason(loadingGroup)).toEqual(''); + }); + it('lets the rollout options exclude each other', async () => { + const target = { deploymentDeviceCount: 5, filter, group: 'testGroupDynamic' }; + expect(getRolloutPatternDisabledReason({ ...target, isPaused: true })).toEqual(disabledReasons.pausedPattern); + expect(getPausesDisabledReason({ ...target, usesPattern: true })).toEqual(disabledReasons.patternPauses); + }); +}); describe('deploymentResolver function', () => { const release = { name: 'test-release' }; diff --git a/frontend/src/js/components/deployments/deployment-wizard/validation.ts b/frontend/src/js/components/deployments/deployment-wizard/validation.ts index 68fda0419..2c8289780 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/validation.ts +++ b/frontend/src/js/components/deployments/deployment-wizard/validation.ts @@ -13,6 +13,7 @@ // limitations under the License. import type { Resolver } from 'react-hook-form'; +import { ALL_DEVICES } from '@northern.tech/utils/constants'; import { isEmpty } from '@northern.tech/utils/helpers'; import type { DeploymentFormValues } from './types'; @@ -26,6 +27,53 @@ export const deploymentErrors = { release: 'Please select software to deploy' }; +export const disabledReasons = { + deviceTargetLimit: 'Cannot limit device count when targeting individual devices', + emptyGroupPattern: 'Rollout pattern is not available for empty device groups', + emptyGroupPauses: 'Pauses is not available for empty device groups', + pausedPattern: 'Cannot select rollout pattern when pauses are enabled', + patternPauses: 'Cannot add pauses when using a rollout pattern', + staticGroupLimit: 'Cannot limit device count when targeting a static group' +}; + +type TargetState = Pick & { + devices?: DeploymentDerivedState['devices']; + group?: string | null; + isDeviceCountResolved?: boolean; +}; + +// the advanced options start out available - they only become unavailable once the selected target makes them +// meaningless, or once one of the mutually exclusive rollout options is in use; while the device count of a target is +// still being retrieved it can't rule anything out yet +const isTargetingEmptyGroup = ({ deploymentDeviceCount, group, isDeviceCountResolved }: TargetState) => + !!group && !!isDeviceCountResolved && !deploymentDeviceCount; + +export const getDeviceLimitDisabledReason = ({ devices = [], filter, group }: TargetState) => { + if (filter) { + return ''; + } + if (group && group !== ALL_DEVICES) { + return disabledReasons.staticGroupLimit; + } + // the api only supports limiting deployments to dynamic groups, a limit on directly targeted devices would + // silently be ignored + return devices.length ? disabledReasons.deviceTargetLimit : ''; +}; + +export const getRolloutPatternDisabledReason = ({ isPaused, ...target }: TargetState & { isPaused?: boolean }) => { + if (isTargetingEmptyGroup(target)) { + return disabledReasons.emptyGroupPattern; + } + return isPaused ? disabledReasons.pausedPattern : ''; +}; + +export const getPausesDisabledReason = ({ usesPattern, ...target }: TargetState & { usesPattern?: boolean }) => { + if (isTargetingEmptyGroup(target)) { + return disabledReasons.emptyGroupPauses; + } + return usesPattern ? disabledReasons.patternPauses : ''; +}; + export type DeploymentResolverContext = Pick & { group: string | null }; // the target device count & the preselected devices live outside of the form, so the validation has to run as a From b873f8eb244907d8211dee10d35e24e7200bef3f Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Wed, 5 Aug 2026 00:25:50 +0200 Subject: [PATCH 19/20] test(gui): aligned deployment creation checks w/ software understanding & design Signed-off-by: Manuel Zedel --- .../deployments/CreateDeployment.test.tsx | 33 +++++++++---------- .../deployments/Deployments.test.tsx | 10 +++--- .../03-advanced/04-deployments.spec.ts | 4 +-- .../01-delta-gen-features.spec.ts | 4 +-- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/frontend/src/js/components/deployments/CreateDeployment.test.tsx b/frontend/src/js/components/deployments/CreateDeployment.test.tsx index 31b4d736d..c6500d4ca 100644 --- a/frontend/src/js/components/deployments/CreateDeployment.test.tsx +++ b/frontend/src/js/components/deployments/CreateDeployment.test.tsx @@ -57,9 +57,17 @@ const preloadedState = { } }; +const renderWrapper = ({ deploymentObject = {}, onScheduleSubmit = vi.fn(), preloadedState: preloadedStateProp = preloadedState }) => + render( + + + , + { preloadedState: preloadedStateProp } + ); + describe('CreateDeployment Component', () => { it('renders correctly', async () => { - const { baseElement } = render(, { preloadedState }); + const { baseElement } = renderWrapper({}); const view = baseElement.getElementsByClassName('MuiDrawer-root')[0]; expect(view).toMatchSnapshot(); expect(view).toEqual(expect.not.stringMatching(undefineds)); @@ -116,7 +124,7 @@ describe('CreateDeployment Component', () => { it('accepts a click on an incomplete deployment & points out what is missing', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); const onScheduleSubmit = vi.fn(); - render(, { preloadedState }); + renderWrapper({ onScheduleSubmit }); const submitButton = screen.getByRole('button', { name: /create deployment/i }); expect(submitButton).toBeEnabled(); await user.click(submitButton); @@ -127,7 +135,7 @@ describe('CreateDeployment Component', () => { it('drops an error once its field is taken care of', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - render(, { preloadedState }); + renderWrapper({}); await user.click(screen.getByRole('button', { name: /create deployment/i })); await waitFor(() => expect(screen.getByText(deploymentErrors.group)).toBeVisible()); const groupSelect = screen.getByPlaceholderText(/select a device group/i); @@ -139,7 +147,7 @@ describe('CreateDeployment Component', () => { it('drops an error once the option that caused it is switched off again', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - render(, { preloadedState }); + renderWrapper({}); await user.click(screen.getByRole('button', { name: /advanced options/i })); const limitCheckbox = screen.getByRole('checkbox', { name: /maximum number of devices/i }); await user.click(limitCheckbox); @@ -156,17 +164,7 @@ describe('CreateDeployment Component', () => { ...defaultState, app: { ...defaultState.app, features: { ...defaultState.app.features, isEnterprise: true } } }; - render( - - - , - { preloadedState: enterpriseState } - ); + renderWrapper({ deploymentObject: { group: ALL_DEVICES, release: defaultState.releases.byId.r1 }, preloadedState: enterpriseState }); await user.click(screen.getByText(/start immediately/i)); await user.click(await screen.findByRole('option', { name: /schedule the start date/i })); await user.click(await screen.findByRole('gridcell', { name: '28' })); @@ -186,13 +184,12 @@ describe('CreateDeployment Component', () => { it('expands the advanced options to show an error hidden in them', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); - const phases = [{ batch_size: 50, delay: 2, delayUnit: 'hours' }, { batch_size: 55, delay: 2, delayUnit: 'hours' }, { batch_size: 95 }]; - render(, { preloadedState }); + renderWrapper({ deploymentObject: { group: 'testGroupDynamic', maxDevices: -90 } }); const accordionToggle = screen.getByRole('button', { name: /advanced options/i }); expect(accordionToggle).toHaveAttribute('aria-expanded', 'false'); await user.click(screen.getByRole('button', { name: /create deployment/i })); await waitFor(() => expect(accordionToggle).toHaveAttribute('aria-expanded', 'true')); - expect(screen.getByText(deploymentErrors.phases)).toBeVisible(); + expect(screen.getByText(deploymentErrors.maxDevices)).toBeVisible(); }); }); }); diff --git a/frontend/src/js/components/deployments/Deployments.test.tsx b/frontend/src/js/components/deployments/Deployments.test.tsx index ef18d0b76..e6b9ea756 100644 --- a/frontend/src/js/components/deployments/Deployments.test.tsx +++ b/frontend/src/js/components/deployments/Deployments.test.tsx @@ -192,7 +192,7 @@ describe('Deployments Component', () => { await user.click(screen.getByRole('button', { name: /advanced options/i })); await user.click(screen.getByRole('checkbox', { name: /maximum number of devices/i })); await waitFor(() => rerender(ui)); - const limitInput = within(screen.getByText(/Finish deployment after/i)).getByRole('textbox'); + const limitInput = document.querySelector('#maxDevices') as HTMLElement; await user.clear(limitInput); await user.type(limitInput, '123'); const post = vi.spyOn(GeneralApi, 'post'); @@ -381,16 +381,16 @@ describe('Deployments Component', () => { ); const { rerender } = render(ui, { preloadedState }); await user.click(screen.getByRole('button', { name: /Create a deployment/i })); - const releaseId = 'release-998'; + const releaseId = 'release-499'; const groupSelect = screen.getByPlaceholderText(/Select a device group/i); await act(async () => vi.runOnlyPendingTimers()); await user.click(groupSelect); await user.type(groupSelect, 'testGroupDyn'); await user.keyboard(specialKeys.ArrowDown); await user.keyboard(specialKeys.Enter); - await waitFor(() => expect(screen.getByRole('button', { name: /select a release/i })).toBeInTheDocument(), { timeout: 3000 }); - await user.click(screen.getByRole('button', { name: /select a release/i })); - await user.click(screen.getByRole('heading', { name: releaseId })); + await waitFor(() => expect(screen.getByRole('button', { name: /select software/i })).toBeInTheDocument(), { timeout: 3000 }); + await user.click(screen.getByRole('button', { name: /select software/i })); + await user.click(await screen.findByRole('heading', { name: releaseId }, { timeout: 3000 })); await waitFor(() => expect(screen.getByText(/Start immediately/i)).toBeInTheDocument(), { timeout: 3000 }); await selectMaterialUiSelectOption(getSelectWrapper(screen.getByText(/Start immediately/i)), /Schedule the start date/i, user); await waitFor(() => rerender(ui)); diff --git a/frontend/tests/e2e_tests/integration/03-advanced/04-deployments.spec.ts b/frontend/tests/e2e_tests/integration/03-advanced/04-deployments.spec.ts index ee0ed3d47..aecf76fbe 100644 --- a/frontend/tests/e2e_tests/integration/03-advanced/04-deployments.spec.ts +++ b/frontend/tests/e2e_tests/integration/03-advanced/04-deployments.spec.ts @@ -109,7 +109,7 @@ test.describe('Deployments', () => { await page.click('[aria-label="create-deployment"]'); await selectReleaseByName(page, 'mender-demo-artifact'); - await triggerDeploymentCreation(page, expect(page.getByText(/Select a Release to deploy/i)).toHaveCount(0, { timeout: timeouts.tenSeconds })); + await triggerDeploymentCreation(page, expect(page.getByText(/Select software to deploy/i)).toHaveCount(0, { timeout: timeouts.tenSeconds })); await page.getByRole('tab', { name: /finished/i }).click(); const pageContent = page.locator('.rightFluid.container'); const listItem = pageContent.getByRole('listitem').first(); @@ -135,7 +135,7 @@ test.describe('Deployments', () => { await deviceGroupSelect.focus(); await deviceGroupSelect.fill('test'); await page.click(`#deployment-device-group-selection-listbox li:has-text('testgroup')`); - await triggerDeploymentCreation(page, expect(page.getByText(/Select a Release to deploy/i)).toHaveCount(0, { timeout: timeouts.tenSeconds })); + await triggerDeploymentCreation(page, expect(page.getByText(/Select software to deploy/i)).toHaveCount(0, { timeout: timeouts.tenSeconds })); await page.getByRole('tab', { name: /finished/i }).click(); const pageContent = page.locator('.rightFluid.container'); const listItem = pageContent.getByRole('listitem').first(); diff --git a/frontend/tests/e2e_tests/integration/04-qemu-dependent/01-delta-gen-features.spec.ts b/frontend/tests/e2e_tests/integration/04-qemu-dependent/01-delta-gen-features.spec.ts index 8b94f5bd6..dc205ff03 100644 --- a/frontend/tests/e2e_tests/integration/04-qemu-dependent/01-delta-gen-features.spec.ts +++ b/frontend/tests/e2e_tests/integration/04-qemu-dependent/01-delta-gen-features.spec.ts @@ -106,7 +106,7 @@ test.describe('Devices', () => { await page.click('[aria-label="create-deployment"]'); await selectReleaseByName(page, 'snapshot-test'); - await triggerDeploymentCreation(page, expect(page.getByText(/Select a Release to deploy/i)).toHaveCount(0, { timeout: timeouts.tenSeconds })); + await triggerDeploymentCreation(page, expect(page.getByText(/Select software to deploy/i)).toHaveCount(0, { timeout: timeouts.tenSeconds })); await page.getByText('finished').click(); await page .getByRole('listitem') @@ -124,7 +124,7 @@ test.describe('Devices', () => { await page.getByRole('button', { name: /advanced options/i }).click(); await page.getByRole('checkbox', { name: /delta artifacts/i }).click(); - await triggerDeploymentCreation(page, expect(page.getByText(/Select a Release to deploy/i)).toHaveCount(0, { timeout: timeouts.tenSeconds })); + await triggerDeploymentCreation(page, expect(page.getByText(/Select software to deploy/i)).toHaveCount(0, { timeout: timeouts.tenSeconds })); await page.getByRole('listitem').first().waitFor({ timeout: timeouts.sixtySeconds }); }); From ebb132c907def01558c249bd7152418e414a2ecc Mon Sep 17 00:00:00 2001 From: Manuel Zedel Date: Fri, 7 Aug 2026 13:38:54 +0200 Subject: [PATCH 20/20] chore(gui): aligned snapshots w/ the updated deployment creation drawer Signed-off-by: Manuel Zedel --- .../CreateDeployment.test.tsx.snap | 9309 +++++++++-------- .../__snapshots__/RolloutSteps.test.tsx.snap | 36 +- .../__snapshots__/HelpTooltips.test.tsx.snap | 11 - 3 files changed, 4865 insertions(+), 4491 deletions(-) diff --git a/frontend/src/js/components/deployments/__snapshots__/CreateDeployment.test.tsx.snap b/frontend/src/js/components/deployments/__snapshots__/CreateDeployment.test.tsx.snap index 6e0ee1bcd..95bd3225f 100644 --- a/frontend/src/js/components/deployments/__snapshots__/CreateDeployment.test.tsx.snap +++ b/frontend/src/js/components/deployments/__snapshots__/CreateDeployment.test.tsx.snap @@ -217,7 +217,6 @@ exports[`CreateDeployment Component > renders correctly 1`] = ` min-width: -moz-min-content; min-width: min-content; max-width: 400px; - min-height: 96px; } .emotion-10.Mui-focused .MuiAutocomplete-clearIndicator { @@ -369,6 +368,7 @@ exports[`CreateDeployment Component > renders correctly 1`] = ` border: 0; vertical-align: top; width: 100%; + min-width: 400px; } .emotion-12 { @@ -696,18 +696,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-13:focus::-ms-input- border-color: #1565c0; } -.emotion-23 { - -webkit-column-gap: 30px; - column-gap: 30px; - display: grid; - grid-template-columns: max-content max-content; -} - -.emotion-23>p { - margin-top: 24px; -} - -.emotion-25 { +.emotion-24 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -765,40 +754,40 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-13:focus::-ms-input- min-width: 235px; } -.emotion-25::-moz-focus-inner { +.emotion-24::-moz-focus-inner { border-style: none; } -.emotion-25.Mui-disabled { +.emotion-24.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-25 { + .emotion-24 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-25:hover { +.emotion-24:hover { -webkit-text-decoration: none; text-decoration: none; } -.emotion-25.Mui-disabled { +.emotion-24.Mui-disabled { color: rgba(0, 0, 0, 0.26); } -.emotion-25.Mui-disabled { +.emotion-24.Mui-disabled { border: 1px solid rgba(0, 0, 0, 0.12); } -.emotion-25.MuiButton-loading { +.emotion-24.MuiButton-loading { color: transparent; } -.emotion-26 { +.emotion-25 { min-width: 0; -webkit-box-flex: 1; -webkit-flex-grow: 1; @@ -806,16 +795,38 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-13:focus::-ms-input- flex-grow: 1; } -.emotion-27 { +.emotion-26 { display: inherit; margin-right: -4px; margin-left: 8px; } -.emotion-27>*:nth-of-type(1) { +.emotion-26>*:nth-of-type(1) { font-size: 22px; } +.emotion-28 { + color: rgba(0, 0, 0, 0.6); + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + font-size: 0.75rem; + line-height: 1.66; + letter-spacing: 0.03333em; + text-align: left; + margin-top: 3px; + margin-right: 0; + margin-bottom: 0; + margin-left: 0; +} + +.emotion-28.Mui-disabled { + color: rgba(0, 0, 0, 0.38); +} + +.emotion-28.Mui-error { + color: #d32f2f; +} + .emotion-29 { -webkit-user-select: none; -moz-user-select: none; @@ -831,14 +842,10 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-13:focus::-ms-input- transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; fill: currentColor; font-size: 1.25rem; + color: #d32f2f; } .emotion-30 { - color: rgba(0, 0, 0, 0.38); - margin: 15px 0; -} - -.emotion-31 { margin: 0; font: inherit; line-height: inherit; @@ -850,11 +857,22 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-13:focus::-ms-input- --Link-underlineColor: rgba(25, 118, 210, 0.4); } -.emotion-31:hover { +.emotion-30:hover { text-decoration-color: inherit; } -.emotion-37 { +.emotion-35 { + -webkit-column-gap: 30px; + column-gap: 30px; + display: grid; + grid-template-columns: max-content max-content; +} + +.emotion-35>p { + margin-top: 24px; +} + +.emotion-36 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -874,7 +892,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-13:focus::-ms-input- width: min-content; } -.emotion-38 { +.emotion-37 { font-family: "Roboto","Helvetica","Arial",sans-serif; font-weight: 400; font-size: 1rem; @@ -897,38 +915,38 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-13:focus::-ms-input- min-width: 400px; } -.emotion-38.Mui-disabled { +.emotion-37.Mui-disabled { color: rgba(0, 0, 0, 0.38); cursor: default; } -.emotion-38:hover .MuiOutlinedInput-notchedOutline { +.emotion-37:hover .MuiOutlinedInput-notchedOutline { border-color: rgba(0, 0, 0, 0.87); } @media (hover: none) { - .emotion-38:hover .MuiOutlinedInput-notchedOutline { + .emotion-37:hover .MuiOutlinedInput-notchedOutline { border-color: rgba(0, 0, 0, 0.23); } } -.emotion-38.Mui-focused .MuiOutlinedInput-notchedOutline { +.emotion-37.Mui-focused .MuiOutlinedInput-notchedOutline { border-width: 2px; } -.emotion-38.Mui-focused .MuiOutlinedInput-notchedOutline { +.emotion-37.Mui-focused .MuiOutlinedInput-notchedOutline { border-color: #1976d2; } -.emotion-38.Mui-error .MuiOutlinedInput-notchedOutline { +.emotion-37.Mui-error .MuiOutlinedInput-notchedOutline { border-color: #d32f2f; } -.emotion-38.Mui-disabled .MuiOutlinedInput-notchedOutline { +.emotion-37.Mui-disabled .MuiOutlinedInput-notchedOutline { border-color: rgba(0, 0, 0, 0.26); } -.emotion-39 { +.emotion-38 { -moz-appearance: none; -webkit-appearance: none; -webkit-user-select: none; @@ -958,24 +976,24 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-13:focus::-ms-input- padding: 16.5px 14px; } -.emotion-39:focus { +.emotion-38:focus { border-radius: 0; } -.emotion-39.Mui-disabled { +.emotion-38.Mui-disabled { cursor: default; } -.emotion-39[multiple] { +.emotion-38[multiple] { height: auto; } -.emotion-39:not([multiple]) option, -.emotion-39:not([multiple]) optgroup { +.emotion-38:not([multiple]) option, +.emotion-38:not([multiple]) optgroup { background-color: #fff; } -.emotion-39~.MuiInputAdornment-root { +.emotion-38~.MuiInputAdornment-root { position: absolute; top: 50%; -webkit-transform: translateY(-50%); @@ -985,27 +1003,27 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-13:focus::-ms-input- right: calc(var(--_caret, 24px) + (var(--_endAdornment, 28px) - 1.5rem)/2); } -.MuiInputBase-root:has(> .emotion-39) { +.MuiInputBase-root:has(> .emotion-38) { --_endAdornment: 0px; } -.MuiInputBase-root:has(> .emotion-39) { +.MuiInputBase-root:has(> .emotion-38) { --_caret: 32px; } -.MuiInputBase-root:has(> .emotion-39 ~ .MuiInputAdornment-root) { +.MuiInputBase-root:has(> .emotion-38 ~ .MuiInputAdornment-root) { --_endAdornment: 28px; } -.emotion-39:focus { +.emotion-38:focus { border-radius: 4px; } -.emotion-39.emotion-39.emotion-39 { +.emotion-38.emotion-38.emotion-38 { padding-right: calc(var(--_caret, 32px) + var(--_endAdornment, 0px)); } -.emotion-39.MuiSelect-select { +.emotion-38.MuiSelect-select { height: auto; min-height: 1.4375em; text-overflow: ellipsis; @@ -1013,80 +1031,80 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-13:focus::-ms-input- overflow: hidden; } -.emotion-39::-webkit-input-placeholder { +.emotion-38::-webkit-input-placeholder { color: currentColor; opacity: 0.42; -webkit-transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-39::-moz-placeholder { +.emotion-38::-moz-placeholder { color: currentColor; opacity: 0.42; -webkit-transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-39::-ms-input-placeholder { +.emotion-38::-ms-input-placeholder { color: currentColor; opacity: 0.42; -webkit-transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-39:focus { +.emotion-38:focus { outline: 0; } -.emotion-39:invalid { +.emotion-38:invalid { box-shadow: none; } -.emotion-39::-webkit-search-decoration { +.emotion-38::-webkit-search-decoration { -webkit-appearance: none; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-39::-webkit-input-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-38::-webkit-input-placeholder { opacity: 0!important; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-39::-moz-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-38::-moz-placeholder { opacity: 0!important; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-39::-ms-input-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-38::-ms-input-placeholder { opacity: 0!important; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-webkit-input-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-38:focus::-webkit-input-placeholder { opacity: 0.42; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-moz-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-38:focus::-moz-placeholder { opacity: 0.42; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-38:focus::-ms-input-placeholder { opacity: 0.42; } -.emotion-39.Mui-disabled { +.emotion-38.Mui-disabled { opacity: 1; -webkit-text-fill-color: rgba(0, 0, 0, 0.38); } -.emotion-39:-webkit-autofill { +.emotion-38:-webkit-autofill { -webkit-animation-duration: 5000s; animation-duration: 5000s; -webkit-animation-name: mui-auto-fill; animation-name: mui-auto-fill; } -.emotion-39:-webkit-autofill { +.emotion-38:-webkit-autofill { border-radius: inherit; } -.emotion-40 { +.emotion-39 { bottom: 0; left: 0; position: absolute; @@ -1096,7 +1114,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- box-sizing: border-box; } -.emotion-41 { +.emotion-40 { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; @@ -1119,15 +1137,15 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- right: 7px; } -.emotion-41.Mui-disabled { +.emotion-40.Mui-disabled { color: rgba(0, 0, 0, 0.26); } -.emotion-44 { +.emotion-43 { gap: 16px; } -.emotion-45 { +.emotion-44 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -1199,28 +1217,28 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- cursor: pointer; } -.emotion-45::-moz-focus-inner { +.emotion-44::-moz-focus-inner { border-style: none; } -.emotion-45.Mui-disabled { +.emotion-44.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-45 { + .emotion-44 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-45.Mui-disabled { +.emotion-44.Mui-disabled { opacity: 0.38; pointer-events: none; } -.emotion-45 .MuiChip-avatar { +.emotion-44 .MuiChip-avatar { margin-left: 5px; margin-right: -6px; width: 24px; @@ -1229,12 +1247,12 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- font-size: 0.75rem; } -.emotion-45 .MuiChip-icon { +.emotion-44 .MuiChip-icon { margin-left: 5px; margin-right: -6px; } -.emotion-45 .MuiChip-deleteIcon { +.emotion-44 .MuiChip-deleteIcon { -webkit-tap-highlight-color: transparent; color: rgba(0, 0, 0, 0.26); font-size: 22px; @@ -1242,27 +1260,27 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- margin: 0 5px 0 -6px; } -.emotion-45 .MuiChip-deleteIcon:hover { +.emotion-44 .MuiChip-deleteIcon:hover { color: rgba(0, 0, 0, 0.4); } -.emotion-45 .MuiChip-icon { +.emotion-44 .MuiChip-icon { color: #616161; } -.emotion-45:hover { +.emotion-44:hover { background-color: rgba(0, 0, 0, 0.12); } -.emotion-45.Mui-focusVisible { +.emotion-44.Mui-focusVisible { background-color: rgba(0, 0, 0, 0.2); } -.emotion-45:active { +.emotion-44:active { box-shadow: 0px 2px 1px -1px rgba(0,0,0,0.2),0px 1px 1px 0px rgba(0,0,0,0.14),0px 1px 3px 0px rgba(0,0,0,0.12); } -.emotion-46 { +.emotion-45 { overflow: hidden; text-overflow: ellipsis; padding-left: 12px; @@ -1271,368 +1289,215 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- } .emotion-47 { - background-color: #fff; - color: rgba(0, 0, 0, 0.87); - -webkit-transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - box-shadow: var(--Paper-shadow); - background-image: var(--Paper-overlay); + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; position: relative; - -webkit-transition: margin 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: margin 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - overflow-anchor: none; - background-color: rgb(255, 255, 255); - margin-top: 32px; + min-width: 0; + padding: 0; + margin: 0; + border: 0; + vertical-align: top; + max-width: 100%; } -.emotion-47::before { +.emotion-48 { + color: rgba(0, 0, 0, 0.6); + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + font-size: 1rem; + line-height: 1.4375em; + letter-spacing: 0.00938em; + padding: 0; + position: relative; + display: block; + transform-origin: top left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; position: absolute; left: 0; - top: -1px; - right: 0; - height: 1px; - content: ""; - opacity: 1; - background-color: rgba(0, 0, 0, 0.12); - -webkit-transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + top: 0; + -webkit-transform: translate(0, 20px) scale(1); + -moz-transform: translate(0, 20px) scale(1); + -ms-transform: translate(0, 20px) scale(1); + transform: translate(0, 20px) scale(1); + -webkit-transform: translate(0, 17px) scale(1); + -moz-transform: translate(0, 17px) scale(1); + -ms-transform: translate(0, 17px) scale(1); + transform: translate(0, 17px) scale(1); + -webkit-transform: translate(0, -1.5px) scale(0.75); + -moz-transform: translate(0, -1.5px) scale(0.75); + -ms-transform: translate(0, -1.5px) scale(0.75); + transform: translate(0, -1.5px) scale(0.75); + transform-origin: top left; + max-width: 133%; + -webkit-transition: color 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms,-webkit-transform 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms,max-width 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms; + transition: color 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms,transform 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms,max-width 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms; + z-index: 1; + pointer-events: none; + -webkit-transform: translate(14px, 16px) scale(1); + -moz-transform: translate(14px, 16px) scale(1); + -ms-transform: translate(14px, 16px) scale(1); + transform: translate(14px, 16px) scale(1); + max-width: calc(100% - 24px); + -webkit-transform: translate(14px, 9px) scale(1); + -moz-transform: translate(14px, 9px) scale(1); + -ms-transform: translate(14px, 9px) scale(1); + transform: translate(14px, 9px) scale(1); + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: auto; + max-width: calc(133% - 32px); + -webkit-transform: translate(14px, -9px) scale(0.75); + -moz-transform: translate(14px, -9px) scale(0.75); + -ms-transform: translate(14px, -9px) scale(0.75); + transform: translate(14px, -9px) scale(0.75); } -.emotion-47:first-of-type::before { - display: none; +.emotion-48.Mui-focused { + color: #1976d2; } -.emotion-47.Mui-expanded::before { - opacity: 0; +.emotion-48.Mui-disabled { + color: rgba(0, 0, 0, 0.38); } -.emotion-47.Mui-expanded:first-of-type { - margin-top: 0; +.emotion-48.Mui-error { + color: #d32f2f; } -.emotion-47.Mui-expanded:last-of-type { - margin-bottom: 0; +.emotion-49 { + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + font-size: 1rem; + line-height: 1.5; + letter-spacing: 0.00938em; + color: rgba(0, 0, 0, 0.87); + cursor: text; + padding: 0; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: start; + -ms-flex-pack: start; + -webkit-justify-content: flex-start; + justify-content: flex-start; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + position: relative; + box-sizing: border-box; + padding: 0 14px; + border-radius: 4px; } -.emotion-47.Mui-expanded+.emotion-47.Mui-expanded::before { - display: none; +.emotion-49.Mui-disabled { + color: rgba(0, 0, 0, 0.26); + cursor: default; } -.emotion-47.Mui-disabled { - background-color: rgba(0, 0, 0, 0.12); +.emotion-49:hover .MuiPickersOutlinedInput-notchedOutline { + border-color: rgba(0, 0, 0, 0.87); } -.emotion-47.Mui-expanded { - margin: 16px 0; +@media (hover: none) { + .emotion-49:hover .MuiPickersOutlinedInput-notchedOutline { + border-color: rgba(0, 0, 0, 0.23); + } } -.emotion-47:before { - display: none; +.emotion-49.Mui-focused .MuiPickersOutlinedInput-notchedOutline { + border-style: solid; + border-width: 2px; } -.emotion-47 .MuiAccordionSummary-content { - margin: 8px 0px; +.emotion-49.Mui-error .MuiPickersOutlinedInput-notchedOutline { + border-color: #d32f2f; } -.emotion-47.Mui-expanded { - margin: unset; - margin-top: 32px; +.emotion-49.Mui-disabled .MuiPickersOutlinedInput-notchedOutline { + border-color: rgba(0, 0, 0, 0.26); } -.emotion-48 { - all: unset; +.emotion-49.Mui-focused:not(.Mui-error) .MuiPickersOutlinedInput-notchedOutline { + border-color: #1976d2; } -.emotion-49 { - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - justify-content: center; - position: relative; - box-sizing: border-box; - -webkit-tap-highlight-color: transparent; - background-color: transparent; - outline: 0; - border: 0; - margin: 0; - border-radius: 0; - padding: 0; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - vertical-align: middle; - -moz-appearance: none; - -webkit-appearance: none; - -webkit-text-decoration: none; - text-decoration: none; - color: inherit; +.emotion-50 { + direction: ltr; + outline: none; + padding: 4px 0 5px; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-size: inherit; + line-height: 1.4375em; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + outline: none; display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; display: flex; - width: 100%; - min-height: 48px; - padding: 0px 16px; - -webkit-transition: min-height 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: min-height 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; -} - -.emotion-49::-moz-focus-inner { - border-style: none; -} - -.emotion-49.Mui-disabled { - pointer-events: none; - cursor: default; -} - -@media print { - .emotion-49 { - -webkit-print-color-adjust: exact; - color-adjust: exact; - } -} - -.emotion-49.Mui-focusVisible { - background-color: rgba(0, 0, 0, 0.12); -} - -.emotion-49.Mui-disabled { - opacity: 0.38; -} - -.emotion-49:hover:not(.Mui-disabled) { - cursor: pointer; -} - -.emotion-49.Mui-expanded { - min-height: 64px; -} - -.emotion-50 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - text-align: start; - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; - margin: 12px 0; - -webkit-transition: margin 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: margin 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; -} - -.emotion-50.Mui-expanded { - margin: 20px 0; -} - -.emotion-51 { - margin: 0; - font-family: "Roboto","Helvetica","Arial",sans-serif; - font-weight: 500; - font-size: 0.875rem; - line-height: 1.57; - letter-spacing: 0.00714em; -} - -.emotion-52 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - color: rgba(0, 0, 0, 0.54); - -webkit-transform: rotate(0deg); - -moz-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - -webkit-transition: -webkit-transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; -} - -.emotion-52.Mui-expanded { - -webkit-transform: rotate(180deg); - -moz-transform: rotate(180deg); - -ms-transform: rotate(180deg); - transform: rotate(180deg); -} - -.emotion-54 { - height: 0; + -webkit-box-flex-wrap: nowrap; + -webkit-flex-wrap: nowrap; + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; overflow: hidden; - -webkit-transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - visibility: hidden; -} - -.emotion-55 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - width: 100%; -} - -.emotion-56 { - width: 100%; -} - -.emotion-58 { - padding: 8px 16px 16px; -} - -.emotion-63 { - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - position: relative; - min-width: 0; - padding: 0; - margin: 0; - border: 0; - vertical-align: top; - max-width: 150px; - min-width: 130px; + letter-spacing: inherit; + width: 182px; + padding-top: 1px; + padding: 16.5px 0; + padding: 8.5px 0; } -.emotion-64 { - color: rgba(0, 0, 0, 0.6); +.emotion-51 { font-family: "Roboto","Helvetica","Arial",sans-serif; - font-weight: 400; - font-size: 1rem; + font-size: inherit; + letter-spacing: inherit; line-height: 1.4375em; - letter-spacing: 0.00938em; - padding: 0; - position: relative; - display: block; - transform-origin: top left; + display: inline-block; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; - position: absolute; - left: 0; - top: 0; - -webkit-transform: translate(0, 20px) scale(1); - -moz-transform: translate(0, 20px) scale(1); - -ms-transform: translate(0, 20px) scale(1); - transform: translate(0, 20px) scale(1); - -webkit-transform: translate(0, -1.5px) scale(0.75); - -moz-transform: translate(0, -1.5px) scale(0.75); - -ms-transform: translate(0, -1.5px) scale(0.75); - transform: translate(0, -1.5px) scale(0.75); - transform-origin: top left; - max-width: 133%; - -webkit-transition: color 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms,-webkit-transform 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms,max-width 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms; - transition: color 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms,transform 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms,max-width 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms; - z-index: 1; - pointer-events: none; - -webkit-transform: translate(14px, 16px) scale(1); - -moz-transform: translate(14px, 16px) scale(1); - -ms-transform: translate(14px, 16px) scale(1); - transform: translate(14px, 16px) scale(1); - max-width: calc(100% - 24px); - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - pointer-events: auto; - max-width: calc(133% - 32px); - -webkit-transform: translate(14px, -9px) scale(0.75); - -moz-transform: translate(14px, -9px) scale(0.75); - -ms-transform: translate(14px, -9px) scale(0.75); - transform: translate(14px, -9px) scale(0.75); -} - -.emotion-64.Mui-focused { - color: #1976d2; } -.emotion-64.Mui-disabled { - color: rgba(0, 0, 0, 0.38); -} - -.emotion-64.Mui-error { - color: #d32f2f; +.emotion-52 { + white-space: pre; + white-space: pre; + letter-spacing: inherit; } -.emotion-65 { +.emotion-53 { + outline: none; font-family: "Roboto","Helvetica","Arial",sans-serif; - font-weight: 400; - font-size: 1rem; line-height: 1.4375em; - letter-spacing: 0.00938em; - color: rgba(0, 0, 0, 0.87); - box-sizing: border-box; - position: relative; - cursor: text; - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - position: relative; - border-radius: 4px; - --_trailingPad: 14px; - padding-right: var(--_trailingPad); - min-width: 80px; - padding-right: 0px; -} - -.emotion-65.Mui-disabled { - color: rgba(0, 0, 0, 0.38); - cursor: default; -} - -.emotion-65:hover .MuiOutlinedInput-notchedOutline { - border-color: rgba(0, 0, 0, 0.87); -} - -@media (hover: none) { - .emotion-65:hover .MuiOutlinedInput-notchedOutline { - border-color: rgba(0, 0, 0, 0.23); + letter-spacing: inherit; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + outline: none; +} + +@supports (-webkit-app-region: drag) { + .MuiPickersInputBase-root:not(:focus-within) .emotion-53 { + -webkit-user-modify: read-only; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } } -.emotion-65.Mui-focused .MuiOutlinedInput-notchedOutline { - border-width: 2px; -} - -.emotion-65.Mui-focused .MuiOutlinedInput-notchedOutline { - border-color: #1976d2; -} - -.emotion-65.Mui-error .MuiOutlinedInput-notchedOutline { - border-color: #d32f2f; -} - -.emotion-65.Mui-disabled .MuiOutlinedInput-notchedOutline { - border-color: rgba(0, 0, 0, 0.26); -} - -.emotion-65.MuiSelect-root { - --_trailingPad: 0px; -} - -.emotion-67 { +.emotion-71 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; @@ -1645,28 +1510,9 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- white-space: nowrap; color: rgba(0, 0, 0, 0.54); margin-left: 8px; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - max-height: unset; - -webkit-align-self: stretch; - -ms-flex-item-align: stretch; - align-self: stretch; - border-left: 1px solid; - border-color: rgba(0, 0, 0, 0.12); - margin-left: 0px; -} - -.emotion-67 button { - padding-top: 0px; - padding-bottom: 0px; - -webkit-flex: 1; - -ms-flex: 1; - flex: 1; - border-radius: 2px; } -.emotion-68 { +.emotion-72 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -1710,252 +1556,179 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- -webkit-transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; --IconButton-hoverBg: rgba(0, 0, 0, 0.04); + margin-right: -12px; } -.emotion-68::-moz-focus-inner { +.emotion-72::-moz-focus-inner { border-style: none; } -.emotion-68.Mui-disabled { +.emotion-72.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-68 { + .emotion-72 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-68:hover { +.emotion-72:hover { background-color: var(--IconButton-hoverBg); } @media (hover: none) { - .emotion-68:hover { + .emotion-72:hover { background-color: transparent; } } -.emotion-68.Mui-disabled { +.emotion-72.Mui-disabled { background-color: transparent; color: rgba(0, 0, 0, 0.26); } -.emotion-68.MuiIconButton-loading { +.emotion-72.MuiIconButton-loading { color: transparent; } -.emotion-69 { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - width: 1em; - height: 1em; - display: inline-block; - -webkit-flex-shrink: 0; - -ms-flex-negative: 0; - flex-shrink: 0; - -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - fill: currentColor; - font-size: 1.5rem; - -webkit-transform: translateY(2px); - -moz-transform: translateY(2px); - -ms-transform: translateY(2px); - transform: translateY(2px); -} - -.emotion-71 { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - width: 1em; - height: 1em; - display: inline-block; - -webkit-flex-shrink: 0; - -ms-flex-negative: 0; - flex-shrink: 0; - -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - fill: currentColor; - font-size: 1.5rem; - -webkit-transform: translateY(-2px); - -moz-transform: translateY(-2px); - -ms-transform: translateY(-2px); - transform: translateY(-2px); -} - .emotion-74 { - color: rgba(0, 0, 0, 0.6); - font-family: "Roboto","Helvetica","Arial",sans-serif; - font-weight: 400; - font-size: 0.75rem; - line-height: 1.66; - letter-spacing: 0.03333em; text-align: left; - margin-top: 3px; - margin-right: 0; - margin-bottom: 0; - margin-left: 0; - margin-left: 14px; - margin-right: 14px; - margin-left: 0px; + position: absolute; + bottom: 0; + right: 0; + top: -5px; + left: 0; + margin: 0; + padding: 0 8px; + pointer-events: none; + border-radius: inherit; + border-style: solid; + border-width: 1px; + overflow: hidden; + min-width: 0%; + border-color: rgba(0, 0, 0, 0.23); } -.emotion-74.Mui-disabled { - color: rgba(0, 0, 0, 0.38); +.emotion-75 { + float: unset; + width: auto; + overflow: hidden; + display: block; + padding: 0; + height: 11px; + font-size: 0.75em; + visibility: hidden; + max-width: 0.01px; + -webkit-transition: max-width 50ms cubic-bezier(0.0, 0, 0.2, 1) 0ms; + transition: max-width 50ms cubic-bezier(0.0, 0, 0.2, 1) 0ms; + white-space: nowrap; + max-width: 100%; + -webkit-transition: max-width 100ms cubic-bezier(0.0, 0, 0.2, 1) 50ms; + transition: max-width 100ms cubic-bezier(0.0, 0, 0.2, 1) 50ms; } -.emotion-74.Mui-error { - color: #d32f2f; +.emotion-75>span { + padding-left: 5px; + padding-right: 5px; + display: inline-block; + opacity: 0; + visibility: visible; } -.emotion-74:empty { - margin-top: 0px; +.emotion-76 { + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-size: inherit; } -.emotion-75 { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - width: 1em; - height: 1em; - display: inline-block; - -webkit-flex-shrink: 0; - -ms-flex-negative: 0; - flex-shrink: 0; - -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - fill: currentColor; - font-size: 1.5rem; - color: rgba(0, 0, 0, 0.54); +.emotion-77 { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; } -.emotion-77 { - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - cursor: pointer; - vertical-align: middle; - -webkit-tap-highlight-color: transparent; - margin-left: -11px; - margin-right: 16px; +.emotion-78 { + background-color: #fff; + color: rgba(0, 0, 0, 0.87); + -webkit-transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + box-shadow: var(--Paper-shadow); + background-image: var(--Paper-overlay); + position: relative; + -webkit-transition: margin 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: margin 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + overflow-anchor: none; + background-color: rgb(255, 255, 255); + margin-top: 32px; } -.emotion-77.Mui-disabled { - cursor: default; +.emotion-78::before { + position: absolute; + left: 0; + top: -1px; + right: 0; + height: 1px; + content: ""; + opacity: 1; + background-color: rgba(0, 0, 0, 0.12); + -webkit-transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-77 .MuiFormControlLabel-label.Mui-disabled { - color: rgba(0, 0, 0, 0.38); +.emotion-78:first-of-type::before { + display: none; } -.emotion-78 { - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - justify-content: center; - position: relative; - box-sizing: border-box; - -webkit-tap-highlight-color: transparent; - background-color: transparent; - outline: 0; - border: 0; - margin: 0; - border-radius: 0; - padding: 0; - cursor: pointer; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - vertical-align: middle; - -moz-appearance: none; - -webkit-appearance: none; - -webkit-text-decoration: none; - text-decoration: none; - color: inherit; - padding: 9px; - border-radius: 50%; - color: rgba(0, 0, 0, 0.6); +.emotion-78.Mui-expanded::before { + opacity: 0; } -.emotion-78::-moz-focus-inner { - border-style: none; +.emotion-78.Mui-expanded:first-of-type { + margin-top: 0; } -.emotion-78.Mui-disabled { - pointer-events: none; - cursor: default; +.emotion-78.Mui-expanded:last-of-type { + margin-bottom: 0; } -@media print { - .emotion-78 { - -webkit-print-color-adjust: exact; - color-adjust: exact; - } +.emotion-78.Mui-expanded+.emotion-78.Mui-expanded::before { + display: none; } -.emotion-78:hover { - background-color: rgba(25, 118, 210, 0.04); +.emotion-78.Mui-disabled { + background-color: rgba(0, 0, 0, 0.12); } -.emotion-78.Mui-checked, -.emotion-78.MuiCheckbox-indeterminate { - color: #1976d2; +.emotion-78.Mui-expanded { + margin: 16px 0; } -.emotion-78.Mui-disabled { - color: rgba(0, 0, 0, 0.26); +.emotion-78:before { + display: none; } -@media (hover: none) { - .emotion-78:hover { - background-color: transparent; - } +.emotion-78 .MuiAccordionSummary-content { + margin: 8px 0px; } -.emotion-79 { - cursor: inherit; - position: absolute; - opacity: 0; - width: 100%; - height: 100%; - top: 0; - left: 0; - margin: 0; - padding: 0; - z-index: 1; +.emotion-78.Mui-expanded { + margin: unset; + margin-top: 32px; } -.emotion-81 { - margin: 0; - font-family: "Roboto","Helvetica","Arial",sans-serif; - font-weight: 400; - font-size: 1rem; - line-height: 1.5; - letter-spacing: 0.00938em; +.emotion-79 { + all: unset; } -.emotion-84 { +.emotion-80 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -1988,244 +1761,123 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- -webkit-text-decoration: none; text-decoration: none; color: inherit; - max-width: 100%; - font-family: "Roboto","Helvetica","Arial",sans-serif; - font-size: 0.8125rem; - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - justify-content: center; - height: 32px; - line-height: 1.5; - color: rgba(0, 0, 0, 0.87); - background-color: rgba(0, 0, 0, 0.08); - border-radius: 16px; - white-space: nowrap; - -webkit-transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - cursor: unset; - outline: 0; - -webkit-text-decoration: none; - text-decoration: none; - border: 0; - padding: 0; - vertical-align: middle; - box-sizing: border-box; - background-color: #1976d2; - color: #fff; - background-color: transparent; - border: 1px solid #bdbdbd; - color: #1976d2; - border: 1px solid rgba(25, 118, 210, 0.7); - border-style: dashed; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + width: 100%; + min-height: 48px; + padding: 0px 16px; + -webkit-transition: min-height 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: min-height 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-84::-moz-focus-inner { +.emotion-80::-moz-focus-inner { border-style: none; } -.emotion-84.Mui-disabled { +.emotion-80.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-84 { + .emotion-80 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-84.Mui-disabled { - opacity: 0.38; - pointer-events: none; -} - -.emotion-84 .MuiChip-avatar { - margin-left: 5px; - margin-right: -6px; - width: 24px; - height: 24px; - color: #616161; - font-size: 0.75rem; -} - -.emotion-84 .MuiChip-icon { - margin-left: 5px; - margin-right: -6px; -} - -.emotion-84 .MuiChip-deleteIcon { - -webkit-tap-highlight-color: transparent; - color: rgba(0, 0, 0, 0.26); - font-size: 22px; - cursor: pointer; - margin: 0 5px 0 -6px; -} - -.emotion-84 .MuiChip-deleteIcon:hover { - color: rgba(0, 0, 0, 0.4); -} - -.emotion-84 .MuiChip-avatar { - color: #fff; - background-color: #1565c0; -} - -.emotion-84 .MuiChip-deleteIcon { - color: rgba(255, 255, 255, 0.7); -} - -.emotion-84 .MuiChip-deleteIcon:hover, -.emotion-84 .MuiChip-deleteIcon:active { - color: #fff; -} - -.emotion-84 .MuiChip-icon { - color: #616161; -} - -.emotion-84 .MuiChip-icon { - color: inherit; -} - -.emotion-84.Mui-focusVisible { - background-color: rgba(0, 0, 0, 0.2); -} - -.emotion-84.Mui-focusVisible { - background: #1565c0; -} - -.emotion-84.MuiChip-clickable:hover { - background-color: rgba(0, 0, 0, 0.04); -} - -.emotion-84.Mui-focusVisible { +.emotion-80.Mui-focusVisible { background-color: rgba(0, 0, 0, 0.12); } -.emotion-84 .MuiChip-avatar { - margin-left: 4px; -} - -.emotion-84 .MuiChip-icon { - margin-left: 4px; -} - -.emotion-84 .MuiChip-deleteIcon { - margin-right: 5px; -} - -.emotion-84.MuiChip-clickable:hover { - background-color: rgba(25, 118, 210, 0.04); +.emotion-80.Mui-disabled { + opacity: 0.38; } -.emotion-84.Mui-focusVisible { - background-color: rgba(25, 118, 210, 0.12); +.emotion-80:hover:not(.Mui-disabled) { + cursor: pointer; } -.emotion-84 .MuiChip-deleteIcon { - color: rgba(25, 118, 210, 0.7); +.emotion-80.Mui-expanded { + min-height: 64px; } -.emotion-84 .MuiChip-deleteIcon:hover, -.emotion-84 .MuiChip-deleteIcon:active { - color: #1976d2; +.emotion-81 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + text-align: start; + -webkit-box-flex: 1; + -webkit-flex-grow: 1; + -ms-flex-positive: 1; + flex-grow: 1; + margin: 12px 0; + -webkit-transition: margin 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: margin 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-84 .MuiChip-deleteIcon { - font-size: smaller; +.emotion-81.Mui-expanded { + margin: 20px 0; } -.emotion-84.not-hovering { - border-color: transparent; - color: rgba(0, 0, 0, 0.38); +.emotion-82 { + margin: 0; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 500; + font-size: 0.875rem; + line-height: 1.57; + letter-spacing: 0.00714em; } -.emotion-84.not-hovering .MuiChip-deleteIcon { - color: rgba(0, 0, 0, 0.38); +.emotion-83 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + color: rgba(0, 0, 0, 0.54); + -webkit-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -ms-transform: rotate(0deg); + transform: rotate(0deg); + -webkit-transition: -webkit-transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-84.not-hovering .MuiChip-label { - padding-left: 0; - visibility: collapse; +.emotion-83.Mui-expanded { + -webkit-transform: rotate(180deg); + -moz-transform: rotate(180deg); + -ms-transform: rotate(180deg); + transform: rotate(180deg); } .emotion-85 { - overflow: hidden; - text-overflow: ellipsis; - padding-left: 12px; - padding-right: 12px; - white-space: nowrap; - padding-left: 11px; - padding-right: 11px; -} - -.emotion-86 { height: 0; overflow: hidden; -webkit-transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - height: auto; - width: 0; - -webkit-transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; visibility: hidden; } -.emotion-87 { +.emotion-86 { display: -webkit-box; display: -webkit-flex; display: -ms-flexbox; display: flex; width: 100%; - width: auto; - height: 100%; } -.emotion-88 { +.emotion-87 { width: 100%; - width: auto; - height: 100%; -} - -.emotion-90 { - position: absolute; - top: -6px; - bottom: -4px; - left: -7px; - right: -6.5px; - border: 1px dashed rgba(0, 0, 0, 0.38); - border-radius: 50%; } -.emotion-90.hovering { - border-color: transparent; +.emotion-89 { + padding: 8px 16px 16px; } .emotion-94 { - -webkit-align-items: baseline; - -webkit-box-align: baseline; - -ms-flex-align: baseline; - align-items: baseline; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - margin-top: 16px; - margin-left: calc(1em + 12px); -} - -.emotion-95 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -2241,7 +1893,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- vertical-align: top; } -.emotion-97 { +.emotion-96 { font-family: "Roboto","Helvetica","Arial",sans-serif; font-weight: 400; font-size: 1rem; @@ -2261,41 +1913,48 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- align-items: center; position: relative; border-radius: 4px; + --_trailingPad: 14px; + padding-right: var(--_trailingPad); min-width: 80px; + padding-right: 0px; } -.emotion-97.Mui-disabled { +.emotion-96.Mui-disabled { color: rgba(0, 0, 0, 0.38); cursor: default; } -.emotion-97:hover .MuiOutlinedInput-notchedOutline { +.emotion-96:hover .MuiOutlinedInput-notchedOutline { border-color: rgba(0, 0, 0, 0.87); } @media (hover: none) { - .emotion-97:hover .MuiOutlinedInput-notchedOutline { + .emotion-96:hover .MuiOutlinedInput-notchedOutline { border-color: rgba(0, 0, 0, 0.23); } } -.emotion-97.Mui-focused .MuiOutlinedInput-notchedOutline { +.emotion-96.Mui-focused .MuiOutlinedInput-notchedOutline { border-width: 2px; } -.emotion-97.Mui-focused .MuiOutlinedInput-notchedOutline { +.emotion-96.Mui-focused .MuiOutlinedInput-notchedOutline { border-color: #1976d2; } -.emotion-97.Mui-error .MuiOutlinedInput-notchedOutline { +.emotion-96.Mui-error .MuiOutlinedInput-notchedOutline { border-color: #d32f2f; } -.emotion-97.Mui-disabled .MuiOutlinedInput-notchedOutline { +.emotion-96.Mui-disabled .MuiOutlinedInput-notchedOutline { border-color: rgba(0, 0, 0, 0.26); } -.emotion-98 { +.emotion-96.MuiSelect-root { + --_trailingPad: 0px; +} + +.emotion-97 { font: inherit; letter-spacing: inherit; color: currentColor; @@ -2313,113 +1972,120 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-39:focus::-ms-input- animation-name: mui-auto-fill-cancel; -webkit-animation-duration: 10ms; animation-duration: 10ms; + padding-top: 1px; padding: 16.5px 14px; + padding: 8.5px 14px; + padding-right: 0; } -.emotion-98::-webkit-input-placeholder { +.emotion-97::-webkit-input-placeholder { color: currentColor; opacity: 0.42; -webkit-transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-98::-moz-placeholder { +.emotion-97::-moz-placeholder { color: currentColor; opacity: 0.42; -webkit-transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-98::-ms-input-placeholder { +.emotion-97::-ms-input-placeholder { color: currentColor; opacity: 0.42; -webkit-transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; } -.emotion-98:focus { +.emotion-97:focus { outline: 0; } -.emotion-98:invalid { +.emotion-97:invalid { box-shadow: none; } -.emotion-98::-webkit-search-decoration { +.emotion-97::-webkit-search-decoration { -webkit-appearance: none; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-98::-webkit-input-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-97::-webkit-input-placeholder { opacity: 0!important; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-98::-moz-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-97::-moz-placeholder { opacity: 0!important; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-98::-ms-input-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-97::-ms-input-placeholder { opacity: 0!important; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-98:focus::-webkit-input-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-97:focus::-webkit-input-placeholder { opacity: 0.42; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-98:focus::-moz-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-97:focus::-moz-placeholder { opacity: 0.42; } -label[data-shrink=false]+.MuiInputBase-formControl .emotion-98:focus::-ms-input-placeholder { +label[data-shrink=false]+.MuiInputBase-formControl .emotion-97:focus::-ms-input-placeholder { opacity: 0.42; } -.emotion-98.Mui-disabled { +.emotion-97.Mui-disabled { opacity: 1; -webkit-text-fill-color: rgba(0, 0, 0, 0.38); } -.emotion-98:-webkit-autofill { +.emotion-97:-webkit-autofill { -webkit-animation-duration: 5000s; animation-duration: 5000s; -webkit-animation-name: mui-auto-fill; animation-name: mui-auto-fill; } -.emotion-98:-webkit-autofill { +.emotion-97:-webkit-autofill { border-radius: inherit; } -.emotion-121 { - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; +.emotion-98 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + max-height: 2em; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + white-space: nowrap; + color: rgba(0, 0, 0, 0.54); + margin-left: 8px; -webkit-flex-direction: column; -ms-flex-direction: column; flex-direction: column; - position: relative; - min-width: 0; - padding: 0; - margin: 0; - border: 0; - vertical-align: top; - margin-top: 16px; - max-width: 515px; - width: -webkit-min-content; - width: -moz-min-content; - width: min-content; + max-height: unset; + -webkit-align-self: stretch; + -ms-flex-item-align: stretch; + align-self: stretch; + border-left: 1px solid; + border-color: rgba(0, 0, 0, 0.12); + margin-left: 0px; } -.emotion-122 { - font-family: "Roboto","Helvetica","Arial",sans-serif; - font-weight: 400; - font-size: 1rem; - line-height: 1.4375em; - letter-spacing: 0.00938em; - color: rgba(0, 0, 0, 0.87); - box-sizing: border-box; - position: relative; - cursor: text; +.emotion-98 button { + padding-top: 0px; + padding-bottom: 0px; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + border-radius: 2px; +} + +.emotion-99 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -2428,115 +2094,81 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-98:focus::-ms-input- -webkit-box-align: center; -ms-flex-align: center; align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; position: relative; - border-radius: 4px; - min-width: 400px; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; + background-color: transparent; + outline: 0; + border: 0; + margin: 0; + border-radius: 0; + padding: 0; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + vertical-align: middle; + -moz-appearance: none; + -webkit-appearance: none; + -webkit-text-decoration: none; + text-decoration: none; + color: inherit; + text-align: center; + -webkit-flex: 0 0 auto; + -ms-flex: 0 0 auto; + flex: 0 0 auto; + font-size: 1.5rem; + padding: 8px; + border-radius: 50%; + color: rgba(0, 0, 0, 0.54); + -webkit-transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + --IconButton-hoverBg: rgba(0, 0, 0, 0.04); + padding: 5px; + font-size: 1.125rem; } -.emotion-122.Mui-disabled { - color: rgba(0, 0, 0, 0.38); - cursor: default; +.emotion-99::-moz-focus-inner { + border-style: none; } -.emotion-122:hover .MuiOutlinedInput-notchedOutline { - border-color: rgba(0, 0, 0, 0.87); +.emotion-99.Mui-disabled { + pointer-events: none; + cursor: default; } -@media (hover: none) { - .emotion-122:hover .MuiOutlinedInput-notchedOutline { - border-color: rgba(0, 0, 0, 0.23); +@media print { + .emotion-99 { + -webkit-print-color-adjust: exact; + color-adjust: exact; } } -.emotion-122.Mui-focused .MuiOutlinedInput-notchedOutline { - border-width: 2px; +.emotion-99:hover { + background-color: var(--IconButton-hoverBg); } -.emotion-122.Mui-focused .MuiOutlinedInput-notchedOutline { - border-color: #1976d2; +@media (hover: none) { + .emotion-99:hover { + background-color: transparent; + } } -.emotion-122.Mui-error .MuiOutlinedInput-notchedOutline { - border-color: #d32f2f; +.emotion-99.Mui-disabled { + background-color: transparent; + color: rgba(0, 0, 0, 0.26); } -.emotion-122.Mui-disabled .MuiOutlinedInput-notchedOutline { - border-color: rgba(0, 0, 0, 0.26); +.emotion-99.MuiIconButton-loading { + color: transparent; } -.emotion-128 { - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - cursor: pointer; - vertical-align: middle; - -webkit-tap-highlight-color: transparent; - margin-left: -11px; - margin-right: 16px; - margin-bottom: 0; -} - -.emotion-128.Mui-disabled { - cursor: default; -} - -.emotion-128 .MuiFormControlLabel-label.Mui-disabled { - color: rgba(0, 0, 0, 0.38); -} - -.emotion-144 { - height: 0; - overflow: hidden; - -webkit-transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - visibility: hidden; - min-height: 300px; -} - -.emotion-147 { - background-color: #fff; - color: rgba(0, 0, 0, 0.87); - -webkit-transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - border-radius: 4px; - box-shadow: var(--Paper-shadow); - background-image: var(--Paper-overlay); - font-family: "Roboto","Helvetica","Arial",sans-serif; - font-weight: 400; - font-size: 0.875rem; - line-height: 1.43; - letter-spacing: 0.01071em; - background-color: transparent; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - padding: 6px 16px; - color: rgb(1, 67, 97); - background-color: rgb(229, 246, 253); -} - -.emotion-147 .MuiAlert-icon { - color: #0288d1; -} - -.emotion-148 { - margin-right: 12px; - padding: 7px 0; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - font-size: 22px; - opacity: 0.9; -} - -.emotion-149 { +.emotion-100 { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; @@ -2550,129 +2182,65 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-98:focus::-ms-input- -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; fill: currentColor; - font-size: inherit; + font-size: 1.25rem; + -webkit-transform: translateY(2px); + -moz-transform: translateY(2px); + -ms-transform: translateY(2px); + transform: translateY(2px); } -.emotion-150 { - padding: 8px 0; - min-width: 0; - overflow: auto; +.emotion-102 { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + width: 1em; + height: 1em; + display: inline-block; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + fill: currentColor; + font-size: 1.25rem; + -webkit-transform: translateY(-2px); + -moz-transform: translateY(-2px); + -ms-transform: translateY(-2px); + transform: translateY(-2px); } -.emotion-151 { - max-width: 100%; +.emotion-105 { + color: rgba(0, 0, 0, 0.6); font-family: "Roboto","Helvetica","Arial",sans-serif; - font-size: 0.8125rem; - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - justify-content: center; - height: 32px; - line-height: 1.5; - color: rgba(0, 0, 0, 0.87); - background-color: rgba(0, 0, 0, 0.08); - border-radius: 16px; - white-space: nowrap; - -webkit-transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: background-color 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - cursor: unset; - outline: 0; - -webkit-text-decoration: none; - text-decoration: none; - border: 0; - padding: 0; - vertical-align: middle; - box-sizing: border-box; - background-color: transparent; - border: 1px solid #bdbdbd; - min-width: 88px; -} - -.emotion-151.Mui-disabled { - opacity: 0.38; - pointer-events: none; -} - -.emotion-151 .MuiChip-avatar { - margin-left: 5px; - margin-right: -6px; - width: 24px; - height: 24px; - color: #616161; + font-weight: 400; font-size: 0.75rem; + line-height: 1.66; + letter-spacing: 0.03333em; + text-align: left; + margin-top: 3px; + margin-right: 0; + margin-bottom: 0; + margin-left: 0; + margin-top: 4px; + margin-left: 14px; + margin-right: 14px; + margin-left: 0px; } -.emotion-151 .MuiChip-icon { - margin-left: 5px; - margin-right: -6px; -} - -.emotion-151 .MuiChip-deleteIcon { - -webkit-tap-highlight-color: transparent; - color: rgba(0, 0, 0, 0.26); - font-size: 22px; - cursor: pointer; - margin: 0 5px 0 -6px; -} - -.emotion-151 .MuiChip-deleteIcon:hover { - color: rgba(0, 0, 0, 0.4); -} - -.emotion-151 .MuiChip-icon { - color: #616161; -} - -.emotion-151.MuiChip-clickable:hover { - background-color: rgba(0, 0, 0, 0.04); -} - -.emotion-151.Mui-focusVisible { - background-color: rgba(0, 0, 0, 0.12); -} - -.emotion-151 .MuiChip-avatar { - margin-left: 4px; -} - -.emotion-151 .MuiChip-icon { - margin-left: 4px; -} - -.emotion-151 .MuiChip-deleteIcon { - margin-right: 5px; -} - -.emotion-153 { - min-width: 120px; -} - -.emotion-154 { - height: 32px; - padding-left: 8px; - width: 100%; +.emotion-105.Mui-disabled { + color: rgba(0, 0, 0, 0.38); } -.emotion-155 { - background-color: rgba(0, 0, 0, 0.6); - height: 3px; - width: 100%; - margin-right: -8px; +.emotion-105.Mui-error { + color: #d32f2f; } -.emotion-155 svg { - color: rgba(0, 0, 0, 0.6); +.emotion-105:empty { + margin-top: 0px; } -.emotion-172 { +.emotion-106 { -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; @@ -2687,14 +2255,34 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-98:focus::-ms-input- transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; fill: currentColor; font-size: 1.5rem; - color: #1976d2; + color: rgba(0, 0, 0, 0.54); } -.emotion-172.read { +.emotion-108 { + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + cursor: pointer; + vertical-align: middle; + -webkit-tap-highlight-color: transparent; + margin-left: -11px; + margin-right: 16px; +} + +.emotion-108.Mui-disabled { + cursor: default; +} + +.emotion-108 .MuiFormControlLabel-label.Mui-disabled { color: rgba(0, 0, 0, 0.38); } -.emotion-182 { +.emotion-109 { display: -webkit-inline-box; display: -webkit-inline-flex; display: -ms-inline-flexbox; @@ -2727,74 +2315,193 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-98:focus::-ms-input- -webkit-text-decoration: none; text-decoration: none; color: inherit; - font-family: "Roboto","Helvetica","Arial",sans-serif; - font-weight: 500; - font-size: 0.875rem; - line-height: 1.75; - letter-spacing: 0.02857em; - text-transform: uppercase; - min-width: 64px; - padding: 6px 16px; - border: 0; - border-radius: 4px; - -webkit-transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - padding: 6px 8px; - color: var(--variant-textColor); - background-color: var(--variant-textBg); - --variant-textColor: #1976d2; - --variant-outlinedColor: #1976d2; - --variant-outlinedBorder: rgba(25, 118, 210, 0.5); - --variant-containedColor: #fff; - --variant-containedBg: #1976d2; - -webkit-transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + padding: 9px; + border-radius: 50%; + color: rgba(0, 0, 0, 0.6); } -.emotion-182::-moz-focus-inner { +.emotion-109::-moz-focus-inner { border-style: none; } -.emotion-182.Mui-disabled { +.emotion-109.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-182 { + .emotion-109 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-182:hover { - -webkit-text-decoration: none; - text-decoration: none; +.emotion-109:hover { + background-color: rgba(25, 118, 210, 0.04); } -.emotion-182.Mui-disabled { - color: rgba(0, 0, 0, 0.26); +.emotion-109.Mui-checked, +.emotion-109.MuiCheckbox-indeterminate { + color: #1976d2; } -@media (hover: hover) { - .emotion-182:hover { - --variant-containedBg: #1565c0; - --variant-textBg: rgba(25, 118, 210, 0.04); - --variant-outlinedBorder: #1976d2; - --variant-outlinedBg: rgba(25, 118, 210, 0.04); - } +.emotion-109.Mui-disabled { + color: rgba(0, 0, 0, 0.26); } -.emotion-182.MuiButton-loading { - color: transparent; +@media (hover: none) { + .emotion-109:hover { + background-color: transparent; + } } -.emotion-183 { - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-align-items: center; +.emotion-110 { + cursor: inherit; + position: absolute; + opacity: 0; + width: 100%; + height: 100%; + top: 0; + left: 0; + margin: 0; + padding: 0; + z-index: 1; +} + +.emotion-111 { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + width: 1em; + height: 1em; + display: inline-block; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + fill: currentColor; + font-size: 1.25rem; +} + +.emotion-112 { + margin: 0; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + font-size: 1rem; + line-height: 1.5; + letter-spacing: 0.00938em; +} + +.emotion-115 { + margin: 0; + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + font-size: 1rem; + line-height: 1.5; + letter-spacing: 0.00938em; + color: #1976d2; +} + +.emotion-145 { + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + position: relative; + min-width: 0; + padding: 0; + margin: 0; + border: 0; + vertical-align: top; + margin-top: 16px; + width: 400px; +} + +.emotion-146 { + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + font-size: 1rem; + line-height: 1.4375em; + letter-spacing: 0.00938em; + color: rgba(0, 0, 0, 0.87); + box-sizing: border-box; + position: relative; + cursor: text; + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + position: relative; + border-radius: 4px; +} + +.emotion-146.Mui-disabled { + color: rgba(0, 0, 0, 0.38); + cursor: default; +} + +.emotion-146:hover .MuiOutlinedInput-notchedOutline { + border-color: rgba(0, 0, 0, 0.87); +} + +@media (hover: none) { + .emotion-146:hover .MuiOutlinedInput-notchedOutline { + border-color: rgba(0, 0, 0, 0.23); + } +} + +.emotion-146.Mui-focused .MuiOutlinedInput-notchedOutline { + border-width: 2px; +} + +.emotion-146.Mui-focused .MuiOutlinedInput-notchedOutline { + border-color: #1976d2; +} + +.emotion-146.Mui-error .MuiOutlinedInput-notchedOutline { + border-color: #d32f2f; +} + +.emotion-146.Mui-disabled .MuiOutlinedInput-notchedOutline { + border-color: rgba(0, 0, 0, 0.26); +} + +.emotion-152 { + background: #03a9f4; +} + +.emotion-153 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; + -webkit-box-flex-wrap: wrap; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; +} + +.emotion-156 { + display: -webkit-inline-box; + display: -webkit-inline-flex; + display: -ms-inline-flexbox; + display: inline-flex; + -webkit-align-items: center; -webkit-box-align: center; -ms-flex-align: center; align-items: center; @@ -2822,113 +2529,773 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-98:focus::-ms-input- -webkit-text-decoration: none; text-decoration: none; color: inherit; - font-family: "Roboto","Helvetica","Arial",sans-serif; - font-weight: 500; - font-size: 0.875rem; - line-height: 1.75; - letter-spacing: 0.02857em; - text-transform: uppercase; - min-width: 64px; - padding: 6px 16px; - border: 0; - border-radius: 4px; - -webkit-transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - color: var(--variant-containedColor); - background-color: var(--variant-containedBg); - box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12); - --variant-textColor: #1976d2; - --variant-outlinedColor: #1976d2; - --variant-outlinedBorder: rgba(25, 118, 210, 0.5); - --variant-containedColor: #fff; - --variant-containedBg: #1976d2; - -webkit-transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; - transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + padding: 9px; + border-radius: 50%; + color: rgba(0, 0, 0, 0.6); } -.emotion-183::-moz-focus-inner { +.emotion-156::-moz-focus-inner { border-style: none; } -.emotion-183.Mui-disabled { +.emotion-156.Mui-disabled { pointer-events: none; cursor: default; } @media print { - .emotion-183 { + .emotion-156 { -webkit-print-color-adjust: exact; color-adjust: exact; } } -.emotion-183:hover { - -webkit-text-decoration: none; - text-decoration: none; +.emotion-156.Mui-disabled { + color: rgba(0, 0, 0, 0.26); } -.emotion-183.Mui-disabled { - color: rgba(0, 0, 0, 0.26); +.emotion-156:hover { + background-color: rgba(25, 118, 210, 0.04); } -.emotion-183:hover { - box-shadow: 0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12); +.emotion-156.Mui-checked { + color: #1976d2; } @media (hover: none) { - .emotion-183:hover { - box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2),0px 2px 2px 0px rgba(0,0,0,0.14),0px 1px 5px 0px rgba(0,0,0,0.12); + .emotion-156:hover { + background-color: transparent; } } -.emotion-183:active { - box-shadow: 0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12); +.emotion-158 { + position: relative; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; } -.emotion-183.Mui-focusVisible { - box-shadow: 0px 3px 5px -1px rgba(0,0,0,0.2),0px 6px 10px 0px rgba(0,0,0,0.14),0px 1px 18px 0px rgba(0,0,0,0.12); +.emotion-159 { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + width: 1em; + height: 1em; + display: inline-block; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + fill: currentColor; + font-size: 1.25rem; + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); } -.emotion-183.Mui-disabled { - color: rgba(0, 0, 0, 0.26); - box-shadow: none; - background-color: rgba(0, 0, 0, 0.12); +.emotion-160 { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + width: 1em; + height: 1em; + display: inline-block; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + fill: currentColor; + font-size: 1.25rem; + left: 0; + position: absolute; + -webkit-transform: scale(0); + -moz-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-transition: -webkit-transform 150ms cubic-bezier(0.4, 0, 1, 1) 0ms; + transition: transform 150ms cubic-bezier(0.4, 0, 1, 1) 0ms; + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + -webkit-transition: -webkit-transform 150ms cubic-bezier(0.0, 0, 0.2, 1) 0ms; + transition: transform 150ms cubic-bezier(0.0, 0, 0.2, 1) 0ms; +} + +.emotion-167 { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + width: 1em; + height: 1em; + display: inline-block; + -webkit-flex-shrink: 0; + -ms-flex-negative: 0; + flex-shrink: 0; + -webkit-transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transition: fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + fill: currentColor; + font-size: 1.25rem; + left: 0; + position: absolute; + -webkit-transform: scale(0); + -moz-transform: scale(0); + -ms-transform: scale(0); + transform: scale(0); + -webkit-transition: -webkit-transform 150ms cubic-bezier(0.4, 0, 1, 1) 0ms; + transition: transform 150ms cubic-bezier(0.4, 0, 1, 1) 0ms; } -@media (hover: hover) { - .emotion-183:hover { - --variant-containedBg: #1565c0; - --variant-textBg: rgba(25, 118, 210, 0.04); - --variant-outlinedBorder: #1976d2; - --variant-outlinedBg: rgba(25, 118, 210, 0.04); - } +.emotion-169 { + display: table; + width: 100%; + border-collapse: collapse; + border-spacing: 0; } -.emotion-183.MuiButton-loading { - color: transparent; +.emotion-169 caption { + font-family: "Roboto","Helvetica","Arial",sans-serif; + font-weight: 400; + font-size: 0.875rem; + line-height: 1.43; + letter-spacing: 0.01071em; + padding: 16px; + color: rgba(0, 0, 0, 0.6); + text-align: left; + caption-side: bottom; } -