mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
refactor(edge/updates): sync changes from EE [EE-4288] (#7726)
This commit is contained in:
parent
4fee359247
commit
82e9e2a895
80 changed files with 1099 additions and 1892 deletions
|
@ -1,6 +1,5 @@
|
|||
import { PropsWithChildren, AnchorHTMLAttributes } from 'react';
|
||||
import { UISref, UISrefProps } from '@uirouter/react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
|
@ -15,7 +14,7 @@ export function Link({
|
|||
}: PropsWithChildren<Props> & UISrefProps) {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<UISref className={clsx('no-decoration', className)} {...props}>
|
||||
<UISref className={className} {...props}>
|
||||
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
|
||||
<a title={title} target={props.target}>
|
||||
{children}
|
||||
|
|
|
@ -61,9 +61,7 @@ export function Button({
|
|||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...ariaProps}
|
||||
>
|
||||
{icon && (
|
||||
<Icon icon={icon} size={getIconSize(size)} className="inline-flex" />
|
||||
)}
|
||||
{icon && <Icon icon={icon} size={getIconSize(size)} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
import { EdgeTypes } from '@/react/portainer/environments/types';
|
||||
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
@ -9,8 +8,9 @@ import { PageHeader } from '@@/PageHeader';
|
|||
|
||||
import { Datatable } from './Datatable';
|
||||
|
||||
export function WaitingRoomView() {
|
||||
const router = useRouter();
|
||||
export default withLimitToBE(WaitingRoomView);
|
||||
|
||||
function WaitingRoomView() {
|
||||
const { environments, isLoading, totalCount } = useEnvironmentList({
|
||||
edgeDevice: true,
|
||||
edgeDeviceUntrusted: true,
|
||||
|
@ -18,11 +18,6 @@ export function WaitingRoomView() {
|
|||
types: EdgeTypes,
|
||||
});
|
||||
|
||||
if (process.env.PORTAINER_EDITION !== 'BE') {
|
||||
router.stateService.go('edge.devices');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
|
|
|
@ -1 +1 @@
|
|||
export { WaitingRoomView } from './WaitingRoomView';
|
||||
export { default as WaitingRoomView } from './WaitingRoomView';
|
||||
|
|
38
app/react/hooks/useLimitToBE.tsx
Normal file
38
app/react/hooks/useLimitToBE.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
export function useLimitToBE(defaultPath = 'portainer.home') {
|
||||
const router = useRouter();
|
||||
if (!isBE) {
|
||||
router.stateService.go(defaultPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function withLimitToBE<T>(
|
||||
WrappedComponent: ComponentType<T>,
|
||||
defaultPath = 'portainer.home'
|
||||
): ComponentType<T> {
|
||||
// Try to create a nice displayName for React Dev Tools.
|
||||
const displayName =
|
||||
WrappedComponent.displayName || WrappedComponent.name || 'Component';
|
||||
|
||||
function WrapperComponent(props: T) {
|
||||
const isLimitedToBE = useLimitToBE(defaultPath);
|
||||
|
||||
if (isLimitedToBE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
return <WrappedComponent {...props} />;
|
||||
}
|
||||
|
||||
WrapperComponent.displayName = `withLimitToBE(${displayName})`;
|
||||
|
||||
return WrapperComponent;
|
||||
}
|
|
@ -81,7 +81,10 @@ export function IngressDatatable() {
|
|||
</Authorized>
|
||||
|
||||
<Authorized authorizations="K8sIngressesW">
|
||||
<Link to="kubernetes.ingresses.create" className="space-left">
|
||||
<Link
|
||||
to="kubernetes.ingresses.create"
|
||||
className="space-left no-decoration"
|
||||
>
|
||||
<Button
|
||||
icon={Plus}
|
||||
className="btn-wrapper vertical-center"
|
||||
|
@ -92,7 +95,7 @@ export function IngressDatatable() {
|
|||
</Link>
|
||||
</Authorized>
|
||||
<Authorized authorizations="K8sIngressesW">
|
||||
<Link to="kubernetes.deploy" className="space-left">
|
||||
<Link to="kubernetes.deploy" className="space-left no-decoration">
|
||||
<Button icon={Plus} className="btn-wrapper">
|
||||
Create from manifest
|
||||
</Button>
|
||||
|
|
|
@ -40,6 +40,7 @@ import { EnvironmentItem } from './EnvironmentItem';
|
|||
import { KubeconfigButton } from './KubeconfigButton';
|
||||
import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel';
|
||||
import styles from './EnvironmentList.module.css';
|
||||
import { UpdateBadge } from './UpdateBadge';
|
||||
|
||||
interface Props {
|
||||
onClickItem(environment: Environment): void;
|
||||
|
@ -131,21 +132,27 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||
edgeDevice: false,
|
||||
tagsPartialMatch: true,
|
||||
agentVersions: agentVersions.map((a) => a.value),
|
||||
updateInformation: isBE,
|
||||
};
|
||||
|
||||
const tagsQuery = useTags();
|
||||
|
||||
const { isLoading, environments, totalCount, totalAvailable } =
|
||||
useEnvironmentList(
|
||||
{
|
||||
page,
|
||||
pageLimit,
|
||||
sort: sortByFilter,
|
||||
order: sortByDescending ? 'desc' : 'asc',
|
||||
...environmentsQueryParams,
|
||||
},
|
||||
refetchIfAnyOffline
|
||||
);
|
||||
const {
|
||||
isLoading,
|
||||
environments,
|
||||
totalCount,
|
||||
totalAvailable,
|
||||
updateAvailable,
|
||||
} = useEnvironmentList(
|
||||
{
|
||||
page,
|
||||
pageLimit,
|
||||
sort: sortByFilter,
|
||||
order: sortByDescending ? 'desc' : 'asc',
|
||||
...environmentsQueryParams,
|
||||
},
|
||||
refetchIfAnyOffline
|
||||
);
|
||||
|
||||
const agentVersionsQuery = useAgentVersionsList();
|
||||
|
||||
|
@ -175,9 +182,10 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||
return (
|
||||
<>
|
||||
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
|
||||
|
||||
<TableContainer>
|
||||
<TableTitle icon={HardDrive} label="Environments" />
|
||||
<TableTitle icon={HardDrive} label="Environments">
|
||||
{isBE && updateAvailable && <UpdateBadge />}
|
||||
</TableTitle>
|
||||
|
||||
<TableActions className={styles.actionBar}>
|
||||
<div className={styles.description}>
|
||||
|
|
40
app/react/portainer/HomeView/EnvironmentList/UpdateBadge.tsx
Normal file
40
app/react/portainer/HomeView/EnvironmentList/UpdateBadge.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import _ from 'lodash';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { useSupportedAgentVersions } from '@/react/portainer/environments/update-schedules/queries/useSupportedAgentVersions';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
export function UpdateBadge() {
|
||||
const version = useAgentLatestVersion();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'badge inline-flex items-center px-3 py-2 font-normal border-solid border border-transparent',
|
||||
'bg-blue-3 text-blue-8',
|
||||
'th-dark:bg-blue-8 th-dark:text-white',
|
||||
'th-highcontrast:bg-transparent th-highcontrast:text-white th-highcontrast:border-white'
|
||||
)}
|
||||
>
|
||||
Update Available: Edge Agent {version}
|
||||
<Link
|
||||
to="portainer.endpoints.updateSchedules.create"
|
||||
className={clsx(
|
||||
'badge font-normal ml-2 border-solid border border-transparent',
|
||||
'bg-blue-8 text-blue-3',
|
||||
'th-dark:bg-blue-3 th-dark:text-blue-8 th-dark:hover:bg-blue-5 th-dark:hover:text-blue-8',
|
||||
'th-highcontrast:bg-transparent th-highcontrast:text-white th-highcontrast:hover:bg-gray-warm-7 th-highcontrast:hover:text-white th-highcontrast:border-white'
|
||||
)}
|
||||
>
|
||||
Schedule Update
|
||||
</Link>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useAgentLatestVersion() {
|
||||
const supportedAgentVersionsQuery = useSupportedAgentVersions();
|
||||
|
||||
return _.last(supportedAgentVersionsQuery.data) || '';
|
||||
}
|
|
@ -28,6 +28,7 @@ export interface EnvironmentsQueryParams {
|
|||
provisioned?: boolean;
|
||||
name?: string;
|
||||
agentVersions?: string[];
|
||||
updateInformation?: boolean;
|
||||
}
|
||||
|
||||
export interface GetEnvironmentsOptions {
|
||||
|
@ -46,7 +47,12 @@ export async function getEnvironments(
|
|||
}: GetEnvironmentsOptions = { query: {} }
|
||||
) {
|
||||
if (query.tagIds && query.tagIds.length === 0) {
|
||||
return { totalCount: 0, value: <Environment[]>[] };
|
||||
return {
|
||||
totalCount: 0,
|
||||
value: <Environment[]>[],
|
||||
totalAvailable: 0,
|
||||
updateAvailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const url = buildUrl();
|
||||
|
@ -63,11 +69,13 @@ export async function getEnvironments(
|
|||
const response = await axios.get<Environment[]>(url, { params });
|
||||
const totalCount = response.headers['x-total-count'];
|
||||
const totalAvailable = response.headers['x-total-available'];
|
||||
const updateAvailable = response.headers['x-update-available'] === 'true';
|
||||
|
||||
return {
|
||||
totalCount: parseInt(totalCount, 10),
|
||||
value: response.data,
|
||||
totalAvailable: parseInt(totalAvailable, 10),
|
||||
updateAvailable,
|
||||
};
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
|
|
|
@ -78,5 +78,6 @@ export function useEnvironmentList(
|
|||
environments: data ? data.value : [],
|
||||
totalCount: data ? data.totalCount : 0,
|
||||
totalAvailable: data ? data.totalAvailable : 0,
|
||||
updateAvailable: data ? data.updateAvailable : false,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -131,6 +131,7 @@ export type Environment = {
|
|||
Edge: EnvironmentEdge;
|
||||
SecuritySettings: EnvironmentSecuritySettings;
|
||||
Gpus: { name: string; value: string }[];
|
||||
LocalTimeZone?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -3,14 +3,13 @@ import { Formik, Form as FormikForm } from 'formik';
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import {
|
||||
useRedirectFeatureFlag,
|
||||
FeatureFlag,
|
||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
||||
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||
import { isoDate } from '@/portainer/filters/filters';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { ScheduleType } from '../types';
|
||||
import { useCreateMutation } from '../queries/create';
|
||||
|
@ -18,19 +17,21 @@ import { FormValues } from '../common/types';
|
|||
import { validation } from '../common/validation';
|
||||
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||
import { useList } from '../queries/list';
|
||||
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||
import { NameField } from '../common/NameField';
|
||||
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||
import { BetaAlert } from '../common/BetaAlert';
|
||||
|
||||
const initialValues: FormValues = {
|
||||
name: '',
|
||||
groupIds: [],
|
||||
type: ScheduleType.Update,
|
||||
time: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||
environments: {},
|
||||
};
|
||||
export default withLimitToBE(CreateView);
|
||||
|
||||
function CreateView() {
|
||||
const initialValues: FormValues = {
|
||||
name: '',
|
||||
groupIds: [],
|
||||
type: ScheduleType.Update,
|
||||
version: '',
|
||||
scheduledTime: isoDate(Date.now() + 24 * 60 * 60 * 1000),
|
||||
};
|
||||
|
||||
export function CreateView() {
|
||||
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
||||
const schedulesQuery = useList();
|
||||
|
||||
const createMutation = useCreateMutation();
|
||||
|
@ -49,6 +50,8 @@ export function CreateView() {
|
|||
breadcrumbs="Edge agent update and rollback"
|
||||
/>
|
||||
|
||||
<BetaAlert />
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
|
@ -60,11 +63,25 @@ export function CreateView() {
|
|||
validateOnMount
|
||||
validationSchema={() => validation(schedules)}
|
||||
>
|
||||
{({ isValid }) => (
|
||||
{({ isValid, setFieldValue, values, handleBlur, errors }) => (
|
||||
<FormikForm className="form-horizontal">
|
||||
<NameField />
|
||||
<EdgeGroupsField />
|
||||
<EdgeGroupsField
|
||||
onChange={(value) => setFieldValue('groupIds', value)}
|
||||
value={values.groupIds}
|
||||
onBlur={handleBlur}
|
||||
error={errors.groupIds}
|
||||
/>
|
||||
|
||||
<TextTip color="blue">
|
||||
You can upgrade from any agent version to 2.17 or later
|
||||
only. You can not upgrade to an agent version prior to
|
||||
2.17 . The ability to rollback to originating version is
|
||||
for 2.15.0+ only.
|
||||
</TextTip>
|
||||
|
||||
<ScheduleTypeSelector />
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
|
|
|
@ -1 +1 @@
|
|||
export { CreateView } from './CreateView';
|
||||
export { default as CreateView } from './CreateView';
|
||||
|
|
|
@ -1,20 +1,17 @@
|
|||
import { Settings } from 'lucide-react';
|
||||
import { Formik, Form as FormikForm } from 'formik';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
import { useMemo } from 'react';
|
||||
import { object, SchemaOf } from 'yup';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import {
|
||||
useRedirectFeatureFlag,
|
||||
FeatureFlag,
|
||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
||||
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { InformationPanel } from '@@/InformationPanel';
|
||||
|
||||
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||
import { useItem } from '../queries/useItem';
|
||||
import { validation } from '../common/validation';
|
||||
import { useUpdateMutation } from '../queries/useUpdateMutation';
|
||||
|
@ -23,12 +20,12 @@ import { NameField, nameValidation } from '../common/NameField';
|
|||
import { EdgeGroupsField } from '../common/EdgeGroupsField';
|
||||
import { EdgeUpdateSchedule } from '../types';
|
||||
import { FormValues } from '../common/types';
|
||||
import { ScheduleTypeSelector } from '../common/ScheduleTypeSelector';
|
||||
import { BetaAlert } from '../common/BetaAlert';
|
||||
|
||||
import { ScheduleDetails } from './ScheduleDetails';
|
||||
|
||||
export function ItemView() {
|
||||
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
||||
export default withLimitToBE(ItemView);
|
||||
|
||||
function ItemView() {
|
||||
const {
|
||||
params: { id: idParam },
|
||||
} = useCurrentStateAndParams();
|
||||
|
@ -44,31 +41,31 @@ export function ItemView() {
|
|||
const itemQuery = useItem(id);
|
||||
const schedulesQuery = useList();
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() => (itemQuery.data ? itemQuery.data.time < Date.now() / 1000 : false),
|
||||
[itemQuery.data]
|
||||
);
|
||||
|
||||
if (!itemQuery.data || !schedulesQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = itemQuery.data;
|
||||
const isScheduleActive = item.isActive;
|
||||
|
||||
const schedules = schedulesQuery.data;
|
||||
|
||||
const initialValuesActive: Partial<FormValues> = {
|
||||
name: item.name,
|
||||
};
|
||||
|
||||
const initialValues: FormValues = {
|
||||
name: item.name,
|
||||
groupIds: item.groupIds,
|
||||
groupIds: item.edgeGroupIds,
|
||||
type: item.type,
|
||||
time: item.time,
|
||||
environments: Object.fromEntries(
|
||||
Object.entries(item.status).map(([envId, status]) => [
|
||||
parseInt(envId, 10),
|
||||
status.targetVersion,
|
||||
])
|
||||
),
|
||||
version: item.version,
|
||||
scheduledTime: item.scheduledTime,
|
||||
};
|
||||
|
||||
const environmentsCount = Object.keys(
|
||||
item.environmentsPreviousVersions
|
||||
).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
|
@ -79,13 +76,17 @@ export function ItemView() {
|
|||
]}
|
||||
/>
|
||||
|
||||
<BetaAlert />
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
|
||||
<Widget.Body>
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
initialValues={
|
||||
!isScheduleActive ? initialValues : initialValuesActive
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
updateMutation.mutate(
|
||||
{ id, values },
|
||||
|
@ -102,17 +103,33 @@ export function ItemView() {
|
|||
}}
|
||||
validateOnMount
|
||||
validationSchema={() =>
|
||||
updateValidation(item.id, item.time, schedules)
|
||||
updateValidation(item.id, schedules, isScheduleActive)
|
||||
}
|
||||
>
|
||||
{({ isValid }) => (
|
||||
{({ isValid, setFieldValue, values, handleBlur, errors }) => (
|
||||
<FormikForm className="form-horizontal">
|
||||
<NameField />
|
||||
|
||||
<EdgeGroupsField disabled={isDisabled} />
|
||||
<EdgeGroupsField
|
||||
disabled={isScheduleActive}
|
||||
onChange={(value) => setFieldValue('groupIds', value)}
|
||||
value={
|
||||
isScheduleActive
|
||||
? item.edgeGroupIds
|
||||
: values.groupIds || []
|
||||
}
|
||||
onBlur={handleBlur}
|
||||
error={errors.groupIds}
|
||||
/>
|
||||
|
||||
{isDisabled ? (
|
||||
<ScheduleDetails schedule={item} />
|
||||
{isScheduleActive ? (
|
||||
<InformationPanel>
|
||||
<TextTip color="blue">
|
||||
{environmentsCount} environment(s) will be updated to
|
||||
version {item.version} on {item.scheduledTime} (local
|
||||
time)
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
) : (
|
||||
<ScheduleTypeSelector />
|
||||
)}
|
||||
|
@ -141,10 +158,10 @@ export function ItemView() {
|
|||
|
||||
function updateValidation(
|
||||
itemId: EdgeUpdateSchedule['id'],
|
||||
scheduledTime: number,
|
||||
schedules: EdgeUpdateSchedule[]
|
||||
schedules: EdgeUpdateSchedule[],
|
||||
isScheduleActive: boolean
|
||||
): SchemaOf<{ name: string } | FormValues> {
|
||||
return scheduledTime > Date.now() / 1000
|
||||
return !isScheduleActive
|
||||
? validation(schedules, itemId)
|
||||
: object({ name: nameValidation(schedules, itemId) });
|
||||
}
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { NavTabs } from '@@/NavTabs';
|
||||
|
||||
import { EdgeUpdateSchedule, ScheduleType } from '../types';
|
||||
import { ScheduledTimeField } from '../common/ScheduledTimeField';
|
||||
|
||||
export function ScheduleDetails({
|
||||
schedule,
|
||||
}: {
|
||||
schedule: EdgeUpdateSchedule;
|
||||
}) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<NavTabs
|
||||
options={[
|
||||
{
|
||||
id: ScheduleType.Update,
|
||||
label: 'Update',
|
||||
children: <UpdateDetails schedule={schedule} />,
|
||||
},
|
||||
{
|
||||
id: ScheduleType.Rollback,
|
||||
label: 'Rollback',
|
||||
children: <UpdateDetails schedule={schedule} />,
|
||||
},
|
||||
]}
|
||||
selectedId={schedule.type}
|
||||
onSelect={() => {}}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateDetails({ schedule }: { schedule: EdgeUpdateSchedule }) {
|
||||
const schedulesCount = Object.values(
|
||||
_.groupBy(
|
||||
schedule.status,
|
||||
(status) => `${status.currentVersion}-${status.targetVersion}`
|
||||
)
|
||||
).map((statuses) => ({
|
||||
count: statuses.length,
|
||||
currentVersion: statuses[0].currentVersion,
|
||||
targetVersion: statuses[0].targetVersion,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
{schedulesCount.map(({ count, currentVersion, targetVersion }) => (
|
||||
<div key={`${currentVersion}-${targetVersion}`}>
|
||||
{count} edge device(s) selected for{' '}
|
||||
{schedule.type === ScheduleType.Rollback ? 'rollback' : 'update'}{' '}
|
||||
from v{currentVersion} to v{targetVersion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScheduledTimeField disabled />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1 +1 @@
|
|||
export { ItemView } from './ItemView';
|
||||
export { default as ItemView } from './ItemView';
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import { Clock, Trash2 } from 'lucide-react';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import {
|
||||
FeatureFlag,
|
||||
useRedirectFeatureFlag,
|
||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
@ -15,7 +12,7 @@ import { Link } from '@@/Link';
|
|||
import { useSearchBarState } from '@@/datatables/SearchBar';
|
||||
|
||||
import { useList } from '../queries/list';
|
||||
import { EdgeUpdateSchedule } from '../types';
|
||||
import { EdgeUpdateSchedule, StatusType } from '../types';
|
||||
import { useRemoveMutation } from '../queries/useRemoveMutation';
|
||||
|
||||
import { columns } from './columns';
|
||||
|
@ -24,13 +21,13 @@ import { createStore } from './datatable-store';
|
|||
const storageKey = 'update-schedules-list';
|
||||
const settingsStore = createStore(storageKey);
|
||||
|
||||
export function ListView() {
|
||||
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
|
||||
export default withLimitToBE(ListView);
|
||||
|
||||
export function ListView() {
|
||||
const settings = useStore(settingsStore);
|
||||
const [search, setSearch] = useSearchBarState(storageKey);
|
||||
|
||||
const listQuery = useList();
|
||||
const listQuery = useList(true);
|
||||
|
||||
if (!listQuery.data) {
|
||||
return null;
|
||||
|
@ -61,6 +58,7 @@ export function ListView() {
|
|||
onSortByChange={settings.setSortBy}
|
||||
searchValue={search}
|
||||
onSearchChange={setSearch}
|
||||
isRowSelectable={(row) => row.original.status === StatusType.Pending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -2,9 +2,9 @@ import { Column } from 'react-table';
|
|||
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../../types';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
|
||||
export const created: Column<EdgeUpdateSchedule> = {
|
||||
export const created: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Created',
|
||||
accessor: (row) => isoDateFromTimestamp(row.created),
|
||||
disableFilters: true,
|
||||
|
|
|
@ -4,11 +4,11 @@ import _ from 'lodash';
|
|||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../../types';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
|
||||
export const groups: Column<EdgeUpdateSchedule> = {
|
||||
export const groups: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Groups',
|
||||
accessor: 'groupIds',
|
||||
accessor: 'edgeGroupIds',
|
||||
Cell: GroupsCell,
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
|
@ -18,7 +18,7 @@ export const groups: Column<EdgeUpdateSchedule> = {
|
|||
|
||||
export function GroupsCell({
|
||||
value: groupsIds,
|
||||
}: CellProps<EdgeUpdateSchedule, Array<EdgeGroup['Id']>>) {
|
||||
}: CellProps<EdgeUpdateListItemResponse, Array<EdgeGroup['Id']>>) {
|
||||
const groupsQuery = useEdgeGroups();
|
||||
|
||||
const groups = _.compact(
|
||||
|
|
|
@ -2,9 +2,9 @@ import { CellProps, Column } from 'react-table';
|
|||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../../types';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
|
||||
export const name: Column<EdgeUpdateSchedule> = {
|
||||
export const name: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Name',
|
||||
accessor: 'name',
|
||||
id: 'name',
|
||||
|
@ -15,7 +15,10 @@ export const name: Column<EdgeUpdateSchedule> = {
|
|||
sortType: 'string',
|
||||
};
|
||||
|
||||
export function NameCell({ value: name, row }: CellProps<EdgeUpdateSchedule>) {
|
||||
export function NameCell({
|
||||
value: name,
|
||||
row,
|
||||
}: CellProps<EdgeUpdateListItemResponse>) {
|
||||
return (
|
||||
<Link to=".item" params={{ id: row.original.id }}>
|
||||
{name}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { EdgeUpdateSchedule, StatusType } from '../../types';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
import { StatusType } from '../../types';
|
||||
|
||||
export const scheduleStatus: Column<EdgeUpdateSchedule> = {
|
||||
export const scheduleStatus: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Status',
|
||||
accessor: (row) => row.status,
|
||||
disableFilters: true,
|
||||
|
@ -14,31 +15,17 @@ export const scheduleStatus: Column<EdgeUpdateSchedule> = {
|
|||
|
||||
function StatusCell({
|
||||
value: status,
|
||||
row: { original: schedule },
|
||||
}: CellProps<EdgeUpdateSchedule, EdgeUpdateSchedule['status']>) {
|
||||
if (schedule.time > Date.now() / 1000) {
|
||||
return 'Scheduled';
|
||||
row: {
|
||||
original: { statusMessage },
|
||||
},
|
||||
}: CellProps<
|
||||
EdgeUpdateListItemResponse,
|
||||
EdgeUpdateListItemResponse['status']
|
||||
>) {
|
||||
switch (status) {
|
||||
case StatusType.Failed:
|
||||
return statusMessage;
|
||||
default:
|
||||
return StatusType[status];
|
||||
}
|
||||
|
||||
const statusList = Object.entries(status).map(
|
||||
([environmentId, envStatus]) => ({ ...envStatus, environmentId })
|
||||
);
|
||||
|
||||
if (statusList.length === 0) {
|
||||
return 'No related environments';
|
||||
}
|
||||
|
||||
const error = statusList.find((s) => s.status === StatusType.Failed);
|
||||
|
||||
if (error) {
|
||||
return `Failed: (ID: ${error.environmentId}) ${error.error}`;
|
||||
}
|
||||
|
||||
const pending = statusList.find((s) => s.status === StatusType.Pending);
|
||||
|
||||
if (pending) {
|
||||
return 'Pending';
|
||||
}
|
||||
|
||||
return 'Success';
|
||||
}
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../../types';
|
||||
|
||||
export const scheduledTime: Column<EdgeUpdateSchedule> = {
|
||||
export const scheduledTime: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Scheduled Time & Date',
|
||||
accessor: (row) => isoDateFromTimestamp(row.time),
|
||||
accessor: (row) => row.scheduledTime,
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: false,
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { EdgeUpdateSchedule, ScheduleType } from '../../types';
|
||||
import { ScheduleType } from '../../types';
|
||||
import { EdgeUpdateListItemResponse } from '../../queries/list';
|
||||
|
||||
export const scheduleType: Column<EdgeUpdateSchedule> = {
|
||||
export const scheduleType: Column<EdgeUpdateListItemResponse> = {
|
||||
Header: 'Type',
|
||||
accessor: (row) => ScheduleType[row.type],
|
||||
disableFilters: true,
|
||||
|
|
|
@ -1 +1 @@
|
|||
export { ListView } from './ListView';
|
||||
export { default as ListView } from './ListView';
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import { InformationPanel } from '@@/InformationPanel';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
export function BetaAlert() {
|
||||
return (
|
||||
<InformationPanel title="Limited Feature">
|
||||
<TextTip>
|
||||
This feature is currently in beta and is limited to standalone linux
|
||||
edge devices.
|
||||
</TextTip>
|
||||
</InformationPanel>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { useField } from 'formik';
|
||||
import { FormikErrors, FormikHandlers } from 'formik';
|
||||
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
|
||||
|
@ -9,14 +9,21 @@ import { FormValues } from './types';
|
|||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
onBlur: FormikHandlers['handleBlur'];
|
||||
value: FormValues['groupIds'];
|
||||
error?: FormikErrors<FormValues>['groupIds'];
|
||||
onChange(value: FormValues['groupIds']): void;
|
||||
}
|
||||
|
||||
export function EdgeGroupsField({ disabled }: Props) {
|
||||
export function EdgeGroupsField({
|
||||
disabled,
|
||||
onBlur,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const groupsQuery = useEdgeGroups();
|
||||
|
||||
const [{ name, onBlur, value }, { error }, { setValue }] =
|
||||
useField<FormValues['groupIds']>('groupIds');
|
||||
|
||||
const selectedGroups = groupsQuery.data?.filter((group) =>
|
||||
value.includes(group.Id)
|
||||
);
|
||||
|
@ -24,12 +31,12 @@ export function EdgeGroupsField({ disabled }: Props) {
|
|||
return (
|
||||
<FormControl label="Groups" required inputId="groups-select" errors={error}>
|
||||
<Select
|
||||
name={name}
|
||||
name="groupIds"
|
||||
onBlur={onBlur}
|
||||
value={selectedGroups}
|
||||
inputId="groups-select"
|
||||
placeholder="Select one or multiple group(s)"
|
||||
onChange={(selectedGroups) => setValue(selectedGroups.map((g) => g.Id))}
|
||||
onChange={(selectedGroups) => onChange(selectedGroups.map((g) => g.Id))}
|
||||
isMulti
|
||||
options={groupsQuery.data || []}
|
||||
getOptionLabel={(group) => group.Name}
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { ActiveSchedule } from '../queries/useActiveSchedules';
|
||||
import { useSupportedAgentVersions } from '../queries/useSupportedAgentVersions';
|
||||
|
||||
import { EnvironmentSelectionItem } from './EnvironmentSelectionItem';
|
||||
import { compareVersion } from './utils';
|
||||
|
||||
interface Props {
|
||||
environments: Environment[];
|
||||
activeSchedules: ActiveSchedule[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function EnvironmentSelection({
|
||||
environments,
|
||||
activeSchedules,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const supportedAgentVersionsQuery = useSupportedAgentVersions({
|
||||
select: (versions) =>
|
||||
versions.map((version) => ({ label: version, value: version })),
|
||||
});
|
||||
|
||||
if (!supportedAgentVersionsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supportedAgentVersions = supportedAgentVersionsQuery.data;
|
||||
|
||||
const latestVersion = _.last(supportedAgentVersions)?.value;
|
||||
|
||||
const environmentsToUpdate = environments.filter(
|
||||
(env) =>
|
||||
activeSchedules.every((schedule) => schedule.environmentId !== env.Id) &&
|
||||
compareVersion(env.Agent.Version, latestVersion)
|
||||
);
|
||||
|
||||
const versionGroups = Object.entries(
|
||||
_.mapValues(
|
||||
_.groupBy(environmentsToUpdate, (env) => env.Agent.Version),
|
||||
(envs) => envs.map((env) => env.Id)
|
||||
)
|
||||
);
|
||||
|
||||
if (environmentsToUpdate.length === 0) {
|
||||
return (
|
||||
<TextTip>
|
||||
The are no update options available for yor selected groups(s)
|
||||
</TextTip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
{versionGroups.map(([version, environmentIds]) => (
|
||||
<EnvironmentSelectionItem
|
||||
currentVersion={version}
|
||||
environmentIds={environmentIds}
|
||||
key={version}
|
||||
versions={supportedAgentVersions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,85 +0,0 @@
|
|||
import { useField } from 'formik';
|
||||
import _ from 'lodash';
|
||||
import { useState, ChangeEvent } from 'react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Select } from '@@/form-components/Input';
|
||||
import { Checkbox } from '@@/form-components/Checkbox';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { compareVersion } from './utils';
|
||||
|
||||
interface Props {
|
||||
currentVersion: string;
|
||||
environmentIds: EnvironmentId[];
|
||||
versions: { label: string; value: string }[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function EnvironmentSelectionItem({
|
||||
environmentIds,
|
||||
versions,
|
||||
currentVersion = 'unknown',
|
||||
disabled,
|
||||
}: Props) {
|
||||
const [{ value }, , { setValue }] =
|
||||
useField<FormValues['environments']>('environments');
|
||||
const isChecked = environmentIds.every((envId) => !!value[envId]);
|
||||
const supportedVersions = versions.filter(
|
||||
({ value }) => compareVersion(currentVersion, value) // versions that are bigger than the current version
|
||||
);
|
||||
|
||||
const maxVersion = _.last(supportedVersions)?.value;
|
||||
|
||||
const [selectedVersion, setSelectedVersion] = useState(
|
||||
value[environmentIds[0]] || maxVersion || ''
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
className="flex items-center"
|
||||
id={`version_checkbox_${currentVersion}`}
|
||||
checked={isChecked}
|
||||
onChange={() => handleChange(!isChecked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<span className="font-normal flex items-center whitespace-nowrap gap-1">
|
||||
{environmentIds.length} edge agents update from v{currentVersion} to
|
||||
<Select
|
||||
disabled={disabled}
|
||||
value={selectedVersion}
|
||||
options={supportedVersions}
|
||||
onChange={handleVersionChange}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleVersionChange(e: ChangeEvent<HTMLSelectElement>) {
|
||||
const version = e.target.value;
|
||||
setSelectedVersion(version);
|
||||
if (isChecked) {
|
||||
handleChange(isChecked, version);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(isChecked: boolean, version: string = selectedVersion) {
|
||||
const newValue = !isChecked
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).filter(
|
||||
([envId]) => !environmentIds.includes(parseInt(envId, 10))
|
||||
)
|
||||
)
|
||||
: {
|
||||
...value,
|
||||
...Object.fromEntries(
|
||||
environmentIds.map((envId) => [envId, version])
|
||||
),
|
||||
};
|
||||
|
||||
setValue(newValue);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
import _ from 'lodash';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { usePreviousVersions } from '../queries/usePreviousVersions';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
|
||||
|
||||
export function RollbackOptions() {
|
||||
const { isLoading, count, version, versionError } = useSelectVersionOnMount();
|
||||
|
||||
const groupNames = useGroupNames();
|
||||
|
||||
if (versionError) {
|
||||
return <TextTip>{versionError}</TextTip>;
|
||||
}
|
||||
|
||||
if (!count) {
|
||||
return (
|
||||
<TextTip>
|
||||
The are no rollback options available for yor selected groups(s)
|
||||
</TextTip>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !groupNames) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
{count} edge device(s) from {groupNames} will rollback to version{' '}
|
||||
{version}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSelectVersionOnMount() {
|
||||
const {
|
||||
values: { groupIds, version },
|
||||
setFieldValue,
|
||||
setFieldError,
|
||||
errors: { version: versionError },
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
const environmentIdsQuery = useEdgeGroupsEnvironmentIds(groupIds);
|
||||
|
||||
const previousVersionsQuery = usePreviousVersions<string[]>({
|
||||
enabled: !!environmentIdsQuery.data,
|
||||
});
|
||||
|
||||
const previousVersions = useMemo(
|
||||
() =>
|
||||
previousVersionsQuery.data
|
||||
? _.uniq(
|
||||
_.compact(
|
||||
environmentIdsQuery.data?.map(
|
||||
(envId) => previousVersionsQuery.data[envId]
|
||||
)
|
||||
)
|
||||
)
|
||||
: [],
|
||||
[environmentIdsQuery.data, previousVersionsQuery.data]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
switch (previousVersions.length) {
|
||||
case 0:
|
||||
setFieldError('version', 'No rollback options available');
|
||||
break;
|
||||
case 1:
|
||||
setFieldValue('version', previousVersions[0]);
|
||||
break;
|
||||
default:
|
||||
setFieldError(
|
||||
'version',
|
||||
'Rollback is not available for these edge group as there are multiple version types to rollback to'
|
||||
);
|
||||
}
|
||||
}, [previousVersions, setFieldError, setFieldValue]);
|
||||
|
||||
return {
|
||||
isLoading: previousVersionsQuery.isLoading,
|
||||
versionError,
|
||||
version,
|
||||
count: environmentIdsQuery.data?.length,
|
||||
};
|
||||
}
|
||||
|
||||
function useGroupNames() {
|
||||
const {
|
||||
values: { groupIds },
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
const groupsQuery = useEdgeGroups({
|
||||
select: (groups) => Object.fromEntries(groups.map((g) => [g.Id, g.Name])),
|
||||
});
|
||||
|
||||
if (!groupsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return groupIds.map((id) => groupsQuery.data[id]).join(', ');
|
||||
}
|
|
@ -1,95 +1,11 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { usePreviousVersions } from '../queries/usePreviousVersions';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
|
||||
import { RollbackOptions } from './RollbackOptions';
|
||||
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||
|
||||
export function RollbackScheduleDetailsFieldset() {
|
||||
const environmentsCount = useSelectedEnvironmentsCount();
|
||||
const { isLoading } = useSelectEnvironmentsOnMount();
|
||||
|
||||
const groupNames = useGroupNames();
|
||||
|
||||
if (isLoading || !groupNames) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
{environmentsCount > 0 ? (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
{environmentsCount} edge device(s) from {groupNames} will rollback
|
||||
to their previous versions
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TextTip>
|
||||
The are no rollback options available for yor selected groups(s)
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
<RollbackOptions />
|
||||
<ScheduledTimeField />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useSelectedEnvironmentsCount() {
|
||||
const {
|
||||
values: { environments },
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
return Object.keys(environments).length;
|
||||
}
|
||||
|
||||
function useSelectEnvironmentsOnMount() {
|
||||
const previousVersionsQuery = usePreviousVersions();
|
||||
|
||||
const {
|
||||
values: { groupIds },
|
||||
setFieldValue,
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
const edgeGroupsEnvironmentIds = useEdgeGroupsEnvironmentIds(groupIds);
|
||||
|
||||
const envIdsToUpdate = useMemo(
|
||||
() =>
|
||||
previousVersionsQuery.data
|
||||
? Object.fromEntries(
|
||||
edgeGroupsEnvironmentIds
|
||||
.map((id) => [id, previousVersionsQuery.data[id] || ''] as const)
|
||||
.filter(([, version]) => !!version)
|
||||
)
|
||||
: [],
|
||||
[edgeGroupsEnvironmentIds, previousVersionsQuery.data]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFieldValue('environments', envIdsToUpdate);
|
||||
}, [envIdsToUpdate, setFieldValue]);
|
||||
|
||||
return { isLoading: previousVersionsQuery.isLoading };
|
||||
}
|
||||
|
||||
function useGroupNames() {
|
||||
const {
|
||||
values: { groupIds },
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
const groupsQuery = useEdgeGroups({
|
||||
select: (groups) => Object.fromEntries(groups.map((g) => [g.Id, g.Name])),
|
||||
});
|
||||
|
||||
if (!groupsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return groupIds.map((id) => groupsQuery.data[id]).join(', ');
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useField } from 'formik';
|
||||
import { useFormikContext } from 'formik';
|
||||
import { number } from 'yup';
|
||||
|
||||
import { NavTabs } from '@@/NavTabs';
|
||||
|
@ -10,7 +10,7 @@ import { UpdateScheduleDetailsFieldset } from './UpdateScheduleDetailsFieldset';
|
|||
import { RollbackScheduleDetailsFieldset } from './RollbackScheduleDetailsFieldset';
|
||||
|
||||
export function ScheduleTypeSelector() {
|
||||
const [{ value }, , { setValue }] = useField<FormValues['type']>('type');
|
||||
const { values, setFieldValue } = useFormikContext<FormValues>();
|
||||
|
||||
return (
|
||||
<div className="form-group">
|
||||
|
@ -28,12 +28,17 @@ export function ScheduleTypeSelector() {
|
|||
children: <RollbackScheduleDetailsFieldset />,
|
||||
},
|
||||
]}
|
||||
selectedId={value}
|
||||
onSelect={(value) => setValue(value)}
|
||||
selectedId={values.type}
|
||||
onSelect={handleChangeType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChangeType(scheduleType: ScheduleType) {
|
||||
setFieldValue('type', scheduleType);
|
||||
setFieldValue('version', '');
|
||||
}
|
||||
}
|
||||
|
||||
export function typeValidation() {
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
import { useField } from 'formik';
|
||||
import DateTimePicker from 'react-datetime-picker';
|
||||
import { Calendar, X } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { string } from 'yup';
|
||||
import { useField } from 'formik';
|
||||
|
||||
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
||||
import {
|
||||
isoDate,
|
||||
parseIsoDate,
|
||||
TIME_FORMAT,
|
||||
} from '@/portainer/filters/filters';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
@ -16,27 +21,49 @@ interface Props {
|
|||
|
||||
export function ScheduledTimeField({ disabled }: Props) {
|
||||
const [{ name, value }, { error }, { setValue }] =
|
||||
useField<FormValues['time']>('time');
|
||||
useField<FormValues['scheduledTime']>('scheduledTime');
|
||||
|
||||
const dateValue = useMemo(() => new Date(value * 1000), [value]);
|
||||
const dateValue = useMemo(() => parseIsoDate(value), [value]);
|
||||
|
||||
return (
|
||||
<FormControl label="Schedule date & time" errors={error}>
|
||||
{!disabled ? (
|
||||
<DateTimePicker
|
||||
format="y-MM-dd HH:mm:ss"
|
||||
minDate={new Date()}
|
||||
className="form-control [&>div]:border-0"
|
||||
onChange={(date) => setValue(Math.floor(date.getTime() / 1000))}
|
||||
onChange={(date) => setValue(isoDate(date.valueOf()))}
|
||||
name={name}
|
||||
value={dateValue}
|
||||
calendarIcon={<Calendar className="lucide" />}
|
||||
clearIcon={<X className="lucide" />}
|
||||
disableClock
|
||||
minDate={new Date(Date.now() - 24 * 60 * 60 * 1000)}
|
||||
/>
|
||||
) : (
|
||||
<Input defaultValue={isoDateFromTimestamp(value)} disabled />
|
||||
<Input defaultValue={value} disabled />
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
export function timeValidation() {
|
||||
return string()
|
||||
.required('Scheduled time is required')
|
||||
.test(
|
||||
'validFormat',
|
||||
`Scheduled time must be in the format ${TIME_FORMAT}`,
|
||||
(value) => isValidDate(parseIsoDate(value))
|
||||
)
|
||||
.test(
|
||||
'validDate',
|
||||
`Scheduled time must be bigger then ${isoDate(
|
||||
Date.now() - 24 * 60 * 60 * 1000
|
||||
)}`,
|
||||
(value) =>
|
||||
parseIsoDate(value).valueOf() > Date.now() - 24 * 60 * 60 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
function isValidDate(date: Date) {
|
||||
return date instanceof Date && !Number.isNaN(date.valueOf());
|
||||
}
|
||||
|
|
|
@ -1,39 +1,58 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import semverCompare from 'semver-compare';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
|
||||
|
||||
import { useActiveSchedules } from '../queries/useActiveSchedules';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||
import { FormValues } from './types';
|
||||
import { EnvironmentSelection } from './EnvironmentSelection';
|
||||
import { ActiveSchedulesNotice } from './ActiveSchedulesNotice';
|
||||
import { useEdgeGroupsEnvironmentIds } from './useEdgeGroupsEnvironmentIds';
|
||||
import { VersionSelect } from './VersionSelect';
|
||||
import { ScheduledTimeField } from './ScheduledTimeField';
|
||||
|
||||
export function UpdateScheduleDetailsFieldset() {
|
||||
const { values } = useFormikContext<FormValues>();
|
||||
|
||||
const edgeGroupsEnvironmentIds = useEdgeGroupsEnvironmentIds(values.groupIds);
|
||||
const environmentIdsQuery = useEdgeGroupsEnvironmentIds(values.groupIds);
|
||||
|
||||
const edgeGroupsEnvironmentIds = environmentIdsQuery.data || [];
|
||||
const environments = useEnvironments(edgeGroupsEnvironmentIds);
|
||||
const activeSchedules = useRelevantActiveSchedules(edgeGroupsEnvironmentIds);
|
||||
const minVersion = _.first(
|
||||
_.compact<string>(environments.map((env) => env.Agent.Version)).sort(
|
||||
(a, b) => semverCompare(a, b)
|
||||
)
|
||||
);
|
||||
|
||||
// old version is version that doesn't support scheduling of updates
|
||||
const hasNoTimeZone = environments.some((env) => !env.LocalTimeZone);
|
||||
const hasTimeZone = environments.some((env) => env.LocalTimeZone);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActiveSchedulesNotice
|
||||
selectedEdgeGroupIds={values.groupIds}
|
||||
activeSchedules={activeSchedules}
|
||||
environments={environments}
|
||||
/>
|
||||
{edgeGroupsEnvironmentIds.length > 0 ? (
|
||||
!!values.version && (
|
||||
<TextTip color="blue">
|
||||
{edgeGroupsEnvironmentIds.length} environment(s) will be updated to{' '}
|
||||
{values.version}
|
||||
</TextTip>
|
||||
)
|
||||
) : (
|
||||
<TextTip color="orange">
|
||||
No environments options for the selected edge groups
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
<EnvironmentSelection
|
||||
activeSchedules={activeSchedules}
|
||||
environments={environments}
|
||||
/>
|
||||
<VersionSelect minVersion={minVersion} />
|
||||
|
||||
<ScheduledTimeField />
|
||||
{hasTimeZone && <ScheduledTimeField />}
|
||||
{hasNoTimeZone && (
|
||||
<TextTip>
|
||||
These edge groups have older versions of the edge agent that do not
|
||||
support scheduling, these will happen immediately
|
||||
</TextTip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -48,17 +67,3 @@ function useEnvironments(environmentsIds: Array<EnvironmentId>) {
|
|||
|
||||
return environmentsQuery.environments;
|
||||
}
|
||||
|
||||
function useRelevantActiveSchedules(environmentIds: EnvironmentId[]) {
|
||||
const { params } = useCurrentStateAndParams();
|
||||
|
||||
const scheduleId = params.id ? parseInt(params.id, 10) : 0;
|
||||
|
||||
const activeSchedulesQuery = useActiveSchedules(environmentIds);
|
||||
|
||||
return (
|
||||
activeSchedulesQuery.data?.filter(
|
||||
(schedule) => schedule.scheduleId !== scheduleId
|
||||
) || []
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import { Field, useField } from 'formik';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Select } from '@@/form-components/Input';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { useSupportedAgentVersions } from '../queries/useSupportedAgentVersions';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
/**
|
||||
* in-case agents don't have any version field, it means they are version less then 2.15.x or that they still not associated.
|
||||
*/
|
||||
const DEFAULT_MIN_VERSION = '2.14.10';
|
||||
|
||||
export function VersionSelect({
|
||||
minVersion = DEFAULT_MIN_VERSION,
|
||||
}: {
|
||||
minVersion?: string;
|
||||
}) {
|
||||
const [{ value: version }, { error }, { setValue }] =
|
||||
useField<FormValues['version']>('version');
|
||||
const supportedAgentVersionsQuery = useSupportedAgentVersions(minVersion, {
|
||||
onSuccess(versions) {
|
||||
if (versions.includes(version)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(_.last(versions) || '');
|
||||
},
|
||||
});
|
||||
|
||||
if (!supportedAgentVersionsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!supportedAgentVersionsQuery.data.length) {
|
||||
return (
|
||||
<FormControl label="Version">
|
||||
<TextTip>No supported versions available</TextTip>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
const supportedVersions = supportedAgentVersionsQuery.data.map((version) => ({
|
||||
label: version,
|
||||
value: version,
|
||||
}));
|
||||
|
||||
return (
|
||||
<FormControl label="Version" errors={error} inputId="version-input">
|
||||
<Field
|
||||
id="version-input"
|
||||
name="version"
|
||||
as={Select}
|
||||
className="form-control"
|
||||
placeholder="Version"
|
||||
options={supportedVersions}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
|
||||
import { ScheduleType } from '../types';
|
||||
|
@ -7,6 +6,6 @@ export interface FormValues {
|
|||
name: string;
|
||||
groupIds: EdgeGroup['Id'][];
|
||||
type: ScheduleType;
|
||||
time: number;
|
||||
environments: Record<EnvironmentId, string>;
|
||||
version: string;
|
||||
scheduledTime: string;
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ export function useEdgeGroupsEnvironmentIds(
|
|||
Object.fromEntries(groups.map((g) => [g.Id, g.Endpoints])),
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
const envIds = useMemo(
|
||||
() =>
|
||||
_.uniq(
|
||||
_.compact(
|
||||
|
@ -23,4 +23,12 @@ export function useEdgeGroupsEnvironmentIds(
|
|||
),
|
||||
[edgeGroupsIds, groupsQuery.data]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
data: groupsQuery.data ? envIds : null,
|
||||
isLoading: groupsQuery.isLoading,
|
||||
}),
|
||||
[envIds, groupsQuery.data, groupsQuery.isLoading]
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { array, number, object } from 'yup';
|
||||
import { array, object, string } from 'yup';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../types';
|
||||
import { EdgeUpdateSchedule, ScheduleType } from '../types';
|
||||
|
||||
import { nameValidation } from './NameField';
|
||||
import { typeValidation } from './ScheduleTypeSelector';
|
||||
|
@ -13,9 +13,15 @@ export function validation(
|
|||
groupIds: array().min(1, 'At least one group is required'),
|
||||
name: nameValidation(schedules, currentId),
|
||||
type: typeValidation(),
|
||||
time: number()
|
||||
.min(Date.now() / 1000)
|
||||
.required(),
|
||||
environments: object().default({}),
|
||||
// time: number()
|
||||
// .min(Date.now() / 1000)
|
||||
// .required(),
|
||||
version: string().when('type', {
|
||||
is: ScheduleType.Update,
|
||||
// update type
|
||||
then: (schema) => schema.required('Version is required'),
|
||||
// rollback
|
||||
otherwise: (schema) => schema.required('No rollback options available'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ async function create(schedule: FormValues) {
|
|||
export function useCreateMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(create, {
|
||||
...withInvalidate(queryClient, [queryKeys.list()]),
|
||||
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||
...withError(),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -2,14 +2,21 @@ import { useQuery } from 'react-query';
|
|||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../types';
|
||||
import { EdgeUpdateResponse, StatusType } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './urls';
|
||||
|
||||
async function getList() {
|
||||
export type EdgeUpdateListItemResponse = EdgeUpdateResponse & {
|
||||
status: StatusType;
|
||||
statusMessage: string;
|
||||
};
|
||||
|
||||
async function getList(includeEdgeStacks?: boolean) {
|
||||
try {
|
||||
const { data } = await axios.get<EdgeUpdateSchedule[]>(buildUrl());
|
||||
const { data } = await axios.get<EdgeUpdateListItemResponse[]>(buildUrl(), {
|
||||
params: { includeEdgeStacks },
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(
|
||||
|
@ -19,6 +26,8 @@ async function getList() {
|
|||
}
|
||||
}
|
||||
|
||||
export function useList() {
|
||||
return useQuery(queryKeys.list(), getList);
|
||||
export function useList(includeEdgeStacks?: boolean) {
|
||||
return useQuery(queryKeys.list(includeEdgeStacks), () =>
|
||||
getList(includeEdgeStacks)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,10 +3,13 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
|||
import { EdgeUpdateSchedule } from '../types';
|
||||
|
||||
export const queryKeys = {
|
||||
list: () => ['edge', 'update_schedules'] as const,
|
||||
item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.list(), id] as const,
|
||||
base: () => ['edge', 'update_schedules'] as const,
|
||||
list: (includeEdgeStacks?: boolean) =>
|
||||
[...queryKeys.base(), { includeEdgeStacks }] as const,
|
||||
item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.base(), id] as const,
|
||||
activeSchedules: (environmentIds: EnvironmentId[]) =>
|
||||
[queryKeys.list(), 'active', { environmentIds }] as const,
|
||||
supportedAgentVersions: () => [queryKeys.list(), 'agent_versions'] as const,
|
||||
previousVersions: () => [queryKeys.list(), 'previous_versions'] as const,
|
||||
[...queryKeys.base(), 'active', { environmentIds }] as const,
|
||||
supportedAgentVersions: () =>
|
||||
[...queryKeys.base(), 'agent_versions'] as const,
|
||||
previousVersions: () => [...queryKeys.base(), 'previous_versions'] as const,
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useQuery } from 'react-query';
|
|||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { EdgeUpdateSchedule } from '../types';
|
||||
import { EdgeUpdateResponse, EdgeUpdateSchedule } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './urls';
|
||||
|
@ -11,9 +11,15 @@ export function useItem(id: EdgeUpdateSchedule['id']) {
|
|||
return useQuery(queryKeys.item(id), () => getItem(id));
|
||||
}
|
||||
|
||||
type EdgeUpdateItemResponse = EdgeUpdateResponse & {
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
async function getItem(id: EdgeUpdateSchedule['id']) {
|
||||
try {
|
||||
const { data } = await axios.get<EdgeUpdateSchedule>(buildUrl(id));
|
||||
const { data } = await axios.get<EdgeUpdateItemResponse>(buildUrl(id), {
|
||||
params: { includeEdgeStack: true },
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(
|
||||
|
|
|
@ -6,11 +6,21 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
|||
import { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './urls';
|
||||
|
||||
interface Options<T> {
|
||||
select?: (data: Record<EnvironmentId, string>) => T;
|
||||
onSuccess?(data: T): void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function usePreviousVersions<T = Record<EnvironmentId, string>>({
|
||||
select,
|
||||
}: { select?: (data: Record<EnvironmentId, string>) => T } = {}) {
|
||||
onSuccess,
|
||||
enabled,
|
||||
}: Options<T> = {}) {
|
||||
return useQuery(queryKeys.previousVersions(), getPreviousVersions, {
|
||||
select,
|
||||
onSuccess,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ export function useRemoveMutation() {
|
|||
),
|
||||
|
||||
mutationOptions(
|
||||
withInvalidate(queryClient, [queryKeys.list()]),
|
||||
withInvalidate(queryClient, [queryKeys.base()]),
|
||||
withError()
|
||||
)
|
||||
);
|
||||
|
|
|
@ -1,17 +1,32 @@
|
|||
import { useQuery } from 'react-query';
|
||||
import semverCompare from 'semver-compare';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './urls';
|
||||
|
||||
export function useSupportedAgentVersions<T = string[]>({
|
||||
select,
|
||||
}: { select?: (data: string[]) => T } = {}) {
|
||||
export function useSupportedAgentVersions(
|
||||
minVersion?: string,
|
||||
{ onSuccess }: { onSuccess?(data: string[]): void } = {}
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.supportedAgentVersions(),
|
||||
[...queryKeys.supportedAgentVersions(), { minVersion }],
|
||||
getSupportedAgentVersions,
|
||||
{ select }
|
||||
{
|
||||
select(versions) {
|
||||
if (!minVersion) {
|
||||
return versions;
|
||||
}
|
||||
|
||||
return versions.filter(
|
||||
(version) => semverCompare(version, minVersion) > 0
|
||||
);
|
||||
},
|
||||
onSuccess,
|
||||
...withError('failed fetching available agent versions'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import { buildUrl } from './urls';
|
|||
|
||||
interface Update {
|
||||
id: EdgeUpdateSchedule['id'];
|
||||
values: FormValues;
|
||||
values: Partial<FormValues>;
|
||||
}
|
||||
|
||||
async function update({ id, values }: Update) {
|
||||
|
@ -30,7 +30,7 @@ async function update({ id, values }: Update) {
|
|||
export function useUpdateMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(update, {
|
||||
...withInvalidate(queryClient, [queryKeys.list()]),
|
||||
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||
...withError(),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,22 +11,23 @@ export enum StatusType {
|
|||
Pending,
|
||||
Failed,
|
||||
Success,
|
||||
}
|
||||
|
||||
interface Status {
|
||||
status: StatusType;
|
||||
error: string;
|
||||
targetVersion: string;
|
||||
currentVersion: string;
|
||||
Sent,
|
||||
}
|
||||
|
||||
export type EdgeUpdateSchedule = {
|
||||
id: number;
|
||||
name: string;
|
||||
time: number;
|
||||
groupIds: EdgeGroup['Id'][];
|
||||
|
||||
type: ScheduleType;
|
||||
status: { [key: EnvironmentId]: Status };
|
||||
|
||||
created: number;
|
||||
createdBy: UserId;
|
||||
version: string;
|
||||
environmentsPreviousVersions: Record<EnvironmentId, string>;
|
||||
};
|
||||
|
||||
export type EdgeUpdateResponse = EdgeUpdateSchedule & {
|
||||
// from edge stack:
|
||||
edgeGroupIds: EdgeGroup['Id'][];
|
||||
scheduledTime: string;
|
||||
};
|
||||
|
|
|
@ -3,7 +3,6 @@ import { useRouter } from '@uirouter/react';
|
|||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||
|
||||
export enum FeatureFlag {
|
||||
EdgeRemoteUpdate = 'edgeRemoteUpdate',
|
||||
BEUpgrade = 'beUpgrade',
|
||||
}
|
||||
|
||||
|
|
|
@ -9,10 +9,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
|
||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||
import {
|
||||
FeatureFlag,
|
||||
useFeatureFlag,
|
||||
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { SidebarItem } from './SidebarItem';
|
||||
import { SidebarSection } from './SidebarSection';
|
||||
|
@ -27,10 +24,6 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
|||
select: (settings) => settings.TeamSync,
|
||||
});
|
||||
|
||||
const isEdgeRemoteUpgradeEnabledQuery = useFeatureFlag(
|
||||
FeatureFlag.EdgeRemoteUpdate
|
||||
);
|
||||
|
||||
const showUsersSection =
|
||||
!window.ddExtension && (isAdmin || (isTeamLeader && !teamSyncQuery.data));
|
||||
|
||||
|
@ -77,7 +70,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
|||
label="Tags"
|
||||
data-cy="portainerSidebar-environmentTags"
|
||||
/>
|
||||
{isEdgeRemoteUpgradeEnabledQuery.data && (
|
||||
{isBE && (
|
||||
<SidebarItem
|
||||
to="portainer.endpoints.updateSchedules"
|
||||
label="Update & Rollback"
|
||||
|
@ -93,7 +86,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
|||
data-cy="portainerSidebar-registries"
|
||||
/>
|
||||
|
||||
{process.env.PORTAINER_EDITION !== 'CE' && (
|
||||
{isBE && (
|
||||
<SidebarItem
|
||||
to="portainer.licenses"
|
||||
label="Licenses"
|
||||
|
@ -136,7 +129,7 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
|||
data-cy="portainerSidebar-authentication"
|
||||
/>
|
||||
)}
|
||||
{process.env.PORTAINER_EDITION !== 'CE' && (
|
||||
{isBE && (
|
||||
<SidebarItem
|
||||
to="portainer.settings.cloud"
|
||||
label="Cloud"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue