1
0
Fork 0
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:
Chaim Lev-Ari 2022-12-01 08:40:52 +02:00 committed by GitHub
parent 4fee359247
commit 82e9e2a895
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
80 changed files with 1099 additions and 1892 deletions

View file

@ -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}

View file

@ -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>
);

View file

@ -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

View file

@ -1 +1 @@
export { WaitingRoomView } from './WaitingRoomView';
export { default as WaitingRoomView } from './WaitingRoomView';

View 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;
}

View file

@ -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>

View file

@ -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}>

View 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) || '';
}

View file

@ -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);

View file

@ -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,
};
}

View file

@ -131,6 +131,7 @@ export type Environment = {
Edge: EnvironmentEdge;
SecuritySettings: EnvironmentSecuritySettings;
Gpus: { name: string; value: string }[];
LocalTimeZone?: string;
};
/**

View file

@ -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

View file

@ -1 +1 @@
export { CreateView } from './CreateView';
export { default as CreateView } from './CreateView';

View file

@ -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) });
}

View file

@ -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 />
</>
);
}

View file

@ -1 +1 @@
export { ItemView } from './ItemView';
export { default as ItemView } from './ItemView';

View file

@ -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}
/>
</>
);

View file

@ -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,

View file

@ -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(

View file

@ -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}

View file

@ -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';
}

View file

@ -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,

View file

@ -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,

View file

@ -1 +1 @@
export { ListView } from './ListView';
export { default as ListView } from './ListView';

View file

@ -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>
);
}

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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);
}
}

View file

@ -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(', ');
}

View file

@ -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(', ');
}

View file

@ -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() {

View file

@ -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());
}

View file

@ -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
) || []
);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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]
);
}

View file

@ -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'),
}),
});
}

View file

@ -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(),
});
}

View file

@ -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)
);
}

View file

@ -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,
};

View file

@ -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(

View file

@ -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,
});
}

View file

@ -23,7 +23,7 @@ export function useRemoveMutation() {
),
mutationOptions(
withInvalidate(queryClient, [queryKeys.list()]),
withInvalidate(queryClient, [queryKeys.base()]),
withError()
)
);

View file

@ -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'),
}
);
}

View file

@ -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(),
});
}

View file

@ -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;
};

View file

@ -3,7 +3,6 @@ import { useRouter } from '@uirouter/react';
import { usePublicSettings } from '@/react/portainer/settings/queries';
export enum FeatureFlag {
EdgeRemoteUpdate = 'edgeRemoteUpdate',
BEUpgrade = 'beUpgrade',
}

View file

@ -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"