1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-09 15:55:23 +02:00

feat(edge/update): remote update structure [EE-4040] (#7553)

This commit is contained in:
Chaim Lev-Ari 2022-09-13 16:56:38 +03:00 committed by GitHub
parent dd1662c8b8
commit 6c4c958bf0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1952 additions and 96 deletions

View file

@ -0,0 +1,96 @@
import { Settings } from 'react-feather';
import { Formik, Form as FormikForm } from 'formik';
import { useRouter } from '@uirouter/react';
import { notifySuccess } from '@/portainer/services/notifications';
import {
useRedirectFeatureFlag,
FeatureFlag,
} from '@/portainer/feature-flags/useRedirectFeatureFlag';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
import { LoadingButton } from '@@/buttons';
import { ScheduleType } from '../types';
import { useCreateMutation } from '../queries/create';
import { FormValues } from '../common/types';
import { validation } from '../common/validation';
import { UpdateTypeTabs } from '../common/UpdateTypeTabs';
import { useList } from '../queries/list';
import { EdgeGroupsField } from '../common/EdgeGroupsField';
import { NameField } from '../common/NameField';
const initialValues: FormValues = {
name: '',
groupIds: [],
type: ScheduleType.Update,
version: 'latest',
time: Math.floor(Date.now() / 1000) + 60 * 60,
};
export function CreateView() {
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
const schedulesQuery = useList();
const createMutation = useCreateMutation();
const router = useRouter();
if (!schedulesQuery.data) {
return null;
}
const schedules = schedulesQuery.data;
return (
<>
<PageHeader
title="Update & Rollback"
breadcrumbs="Edge agent update and rollback"
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
<Widget.Body>
<Formik
initialValues={initialValues}
onSubmit={(values) => {
createMutation.mutate(values, {
onSuccess() {
notifySuccess('Success', 'Created schedule successfully');
router.stateService.go('^');
},
});
}}
validateOnMount
validationSchema={() => validation(schedules)}
>
{({ isValid }) => (
<FormikForm className="form-horizontal">
<NameField />
<EdgeGroupsField />
<UpdateTypeTabs />
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid}
isLoading={createMutation.isLoading}
loadingText="Creating..."
>
Create Schedule
</LoadingButton>
</div>
</div>
</FormikForm>
)}
</Formik>
</Widget.Body>
</Widget>
</div>
</div>
</>
);
}

View file

@ -0,0 +1 @@
export { CreateView } from './CreateView';

View file

@ -0,0 +1,124 @@
import { Settings } from 'react-feather';
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 '@/portainer/feature-flags/useRedirectFeatureFlag';
import { PageHeader } from '@@/PageHeader';
import { Widget } from '@@/Widget';
import { LoadingButton } from '@@/buttons';
import { UpdateTypeTabs } from '../common/UpdateTypeTabs';
import { useItem } from '../queries/useItem';
import { validation } from '../common/validation';
import { useUpdateMutation } from '../queries/useUpdateMutation';
import { useList } from '../queries/list';
import { NameField, nameValidation } from '../common/NameField';
import { EdgeGroupsField } from '../common/EdgeGroupsField';
import { EdgeUpdateSchedule } from '../types';
import { FormValues } from '../common/types';
export function ItemView() {
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
const {
params: { id: idParam },
} = useCurrentStateAndParams();
const id = parseInt(idParam, 10);
if (!idParam || Number.isNaN(id)) {
throw new Error('id is a required path param');
}
const updateMutation = useUpdateMutation();
const router = useRouter();
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 schedules = schedulesQuery.data;
return (
<>
<PageHeader
title="Update & Rollback"
breadcrumbs={['Edge agent update and rollback', item.name]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<Widget.Title title="Update & Rollback Scheduler" icon={Settings} />
<Widget.Body>
<Formik
initialValues={item}
onSubmit={(values) => {
updateMutation.mutate(
{ id, values },
{
onSuccess() {
notifySuccess(
'Success',
'Updated schedule successfully'
);
router.stateService.go('^');
},
}
);
}}
validateOnMount
validationSchema={() => updateValidation(item, schedules)}
>
{({ isValid }) => (
<FormikForm className="form-horizontal">
<NameField />
<EdgeGroupsField disabled={isDisabled} />
<UpdateTypeTabs disabled={isDisabled} />
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
disabled={!isValid}
isLoading={updateMutation.isLoading}
loadingText="Updating..."
>
Update Schedule
</LoadingButton>
</div>
</div>
</FormikForm>
)}
</Formik>
</Widget.Body>
</Widget>
</div>
</div>
</>
);
}
function updateValidation(
item: EdgeUpdateSchedule,
schedules: EdgeUpdateSchedule[]
): SchemaOf<{ name: string } | FormValues> {
return item.time > Date.now() / 1000
? validation(schedules, item.id)
: object({ name: nameValidation(schedules, item.id) });
}

View file

@ -0,0 +1 @@
export { ItemView } from './ItemView';

View file

@ -0,0 +1,99 @@
import { Clock, Trash2 } from 'react-feather';
import {
FeatureFlag,
useRedirectFeatureFlag,
} from '@/portainer/feature-flags/useRedirectFeatureFlag';
import { notifySuccess } from '@/portainer/services/notifications';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { Datatable } from '@@/datatables';
import { PageHeader } from '@@/PageHeader';
import { Button } from '@@/buttons';
import { Link } from '@@/Link';
import { useList } from '../queries/list';
import { EdgeUpdateSchedule } from '../types';
import { useRemoveMutation } from '../queries/useRemoveMutation';
import { columns } from './columns';
import { createStore } from './datatable-store';
const storageKey = 'update-schedules-list';
const useStore = createStore(storageKey);
export function ListView() {
useRedirectFeatureFlag(FeatureFlag.EdgeRemoteUpdate);
const listQuery = useList();
const store = useStore();
if (!listQuery.data) {
return null;
}
return (
<>
<PageHeader
title="Update & Rollback"
reload
breadcrumbs="Update and rollback"
/>
<Datatable
columns={columns}
titleOptions={{
title: 'Update & rollback',
icon: Clock,
}}
dataset={listQuery.data}
settingsStore={store}
storageKey={storageKey}
emptyContentLabel="No schedules found"
isLoading={listQuery.isLoading}
totalCount={listQuery.data.length}
renderTableActions={(selectedRows) => (
<TableActions selectedRows={selectedRows} />
)}
/>
</>
);
}
function TableActions({
selectedRows,
}: {
selectedRows: EdgeUpdateSchedule[];
}) {
const removeMutation = useRemoveMutation();
return (
<>
<Button
icon={Trash2}
color="dangerlight"
onClick={() => handleRemove()}
disabled={selectedRows.length === 0}
>
Remove
</Button>
<Link to=".create">
<Button>Add update & rollback schedule</Button>
</Link>
</>
);
async function handleRemove() {
const confirmed = await confirmDeletionAsync(
'Are you sure you want to remove these?'
);
if (!confirmed) {
return;
}
removeMutation.mutate(selectedRows, {
onSuccess: () => {
notifySuccess('Success', 'Schedules successfully removed');
},
});
}
}

View file

@ -0,0 +1,13 @@
import { Column } from 'react-table';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { EdgeUpdateSchedule } from '../../types';
export const created: Column<EdgeUpdateSchedule> = {
Header: 'Created',
accessor: (row) => isoDateFromTimestamp(row.created),
disableFilters: true,
Filter: () => null,
canHide: false,
};

View file

@ -0,0 +1,29 @@
import { CellProps, Column } from 'react-table';
import _ from 'lodash';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
import { EdgeUpdateSchedule } from '../../types';
export const groups: Column<EdgeUpdateSchedule> = {
Header: 'Groups',
accessor: 'groupIds',
Cell: GroupsCell,
disableFilters: true,
Filter: () => null,
canHide: false,
disableSortBy: true,
};
export function GroupsCell({
value: groupsIds,
}: CellProps<EdgeUpdateSchedule, Array<EdgeGroup['Id']>>) {
const groupsQuery = useEdgeGroups();
const groups = _.compact(
groupsIds.map((id) => groupsQuery.data?.find((g) => g.Id === id))
);
return groups.map((g) => g.Name).join(', ');
}

View file

@ -0,0 +1,15 @@
import { created } from './created';
import { groups } from './groups';
import { name } from './name';
import { scheduleStatus } from './schedule-status';
import { scheduledTime } from './scheduled-time';
import { scheduleType } from './type';
export const columns = [
name,
scheduledTime,
groups,
scheduleType,
scheduleStatus,
created,
];

View file

@ -0,0 +1,24 @@
import { CellProps, Column } from 'react-table';
import { Link } from '@@/Link';
import { EdgeUpdateSchedule } from '../../types';
export const name: Column<EdgeUpdateSchedule> = {
Header: 'Name',
accessor: 'name',
id: 'name',
Cell: NameCell,
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
};
export function NameCell({ value: name, row }: CellProps<EdgeUpdateSchedule>) {
return (
<Link to=".item" params={{ id: row.original.id }}>
{name}
</Link>
);
}

View file

@ -0,0 +1,44 @@
import { CellProps, Column } from 'react-table';
import { EdgeUpdateSchedule, StatusType } from '../../types';
export const scheduleStatus: Column<EdgeUpdateSchedule> = {
Header: 'Status',
accessor: (row) => row.status,
disableFilters: true,
Filter: () => null,
canHide: false,
Cell: StatusCell,
disableSortBy: true,
};
function StatusCell({
value: status,
row: { original: schedule },
}: CellProps<EdgeUpdateSchedule, EdgeUpdateSchedule['status']>) {
if (schedule.time > Date.now() / 1000) {
return 'Scheduled';
}
const statusList = Object.entries(status).map(
([environmentId, envStatus]) => ({ ...envStatus, environmentId })
);
if (statusList.length === 0) {
return 'No related environments';
}
const error = statusList.find((s) => s.Type === StatusType.Failed);
if (error) {
return `Failed: (ID: ${error.environmentId}) ${error.Error}`;
}
const pending = statusList.find((s) => s.Type === StatusType.Pending);
if (pending) {
return 'Pending';
}
return 'Success';
}

View file

@ -0,0 +1,13 @@
import { Column } from 'react-table';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { EdgeUpdateSchedule } from '../../types';
export const scheduledTime: Column<EdgeUpdateSchedule> = {
Header: 'Scheduled Time & Date',
accessor: (row) => isoDateFromTimestamp(row.time),
disableFilters: true,
Filter: () => null,
canHide: false,
};

View file

@ -0,0 +1,12 @@
import { Column } from 'react-table';
import { EdgeUpdateSchedule, ScheduleType } from '../../types';
export const scheduleType: Column<EdgeUpdateSchedule> = {
Header: 'Type',
accessor: (row) => ScheduleType[row.type],
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
};

View file

@ -0,0 +1,36 @@
import create from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
import {
paginationSettings,
sortableSettings,
refreshableSettings,
hiddenColumnsSettings,
PaginationTableSettings,
RefreshableTableSettings,
SettableColumnsTableSettings,
SortableTableSettings,
} from '@/react/components/datatables/types';
interface TableSettings
extends SortableTableSettings,
PaginationTableSettings,
SettableColumnsTableSettings,
RefreshableTableSettings {}
export function createStore(storageKey: string) {
return create<TableSettings>()(
persist(
(set) => ({
...sortableSettings(set),
...paginationSettings(set),
...hiddenColumnsSettings(set),
...refreshableSettings(set),
}),
{
name: keyBuilder(storageKey),
}
)
);
}

View file

@ -0,0 +1 @@
export { ListView } from './ListView';

View file

@ -0,0 +1,42 @@
import { useField } from 'formik';
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
import { FormControl } from '@@/form-components/FormControl';
import { Select } from '@@/form-components/ReactSelect';
import { FormValues } from './types';
interface Props {
disabled?: boolean;
}
export function EdgeGroupsField({ disabled }: Props) {
const groupsQuery = useEdgeGroups();
const [{ name, onBlur, value }, { error }, { setValue }] =
useField<FormValues['groupIds']>('groupIds');
const selectedGroups = groupsQuery.data?.filter((group) =>
value.includes(group.Id)
);
return (
<FormControl label="Groups" required inputId="groups-select" errors={error}>
<Select
name={name}
onBlur={onBlur}
value={selectedGroups}
inputId="groups-select"
placeholder="Select one or multiple group(s)"
onChange={(selectedGroups) => setValue(selectedGroups.map((g) => g.Id))}
isMulti
options={groupsQuery.data || []}
getOptionLabel={(group) => group.Name}
getOptionValue={(group) => group.Id.toString()}
closeMenuOnSelect={false}
isDisabled={disabled}
/>
</FormControl>
);
}

View file

@ -0,0 +1,30 @@
import { Field, useField } from 'formik';
import { string } from 'yup';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { EdgeUpdateSchedule } from '../types';
import { FormValues } from './types';
export function NameField() {
const [{ name }, { error }] = useField<FormValues['name']>('name');
return (
<FormControl label="Name" required inputId="name-input" errors={error}>
<Field as={Input} name={name} id="name-input" />
</FormControl>
);
}
export function nameValidation(
schedules: EdgeUpdateSchedule[],
currentId?: EdgeUpdateSchedule['id']
) {
return string()
.required('This field is required')
.test('unique', 'Name must be unique', (value) =>
schedules.every((s) => s.id === currentId || s.name !== value)
);
}

View file

@ -0,0 +1,42 @@
import { useField } from 'formik';
import DateTimePicker from 'react-datetime-picker';
import { Calendar, X } from 'react-feather';
import { useMemo } from 'react';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { FormValues } from './types';
interface Props {
disabled?: boolean;
}
export function ScheduledTimeField({ disabled }: Props) {
const [{ name, value }, { error }, { setValue }] =
useField<FormValues['time']>('time');
const dateValue = useMemo(() => new Date(value * 1000), [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))}
name={name}
value={dateValue}
calendarIcon={<Calendar className="feather" />}
clearIcon={<X className="feather" />}
disableClock
/>
) : (
<Input defaultValue={isoDateFromTimestamp(value)} disabled />
)}
</FormControl>
);
}

View file

@ -0,0 +1,55 @@
import { useField } from 'formik';
import { number } from 'yup';
import { NavTabs } from '@@/NavTabs';
import { ScheduleType } from '../types';
import { FormValues } from './types';
import { ScheduledTimeField } from './ScheduledTimeField';
interface Props {
disabled?: boolean;
}
export function UpdateTypeTabs({ disabled }: Props) {
const [{ value }, , { setValue }] = useField<FormValues['type']>('type');
return (
<div className="form-group">
<div className="col-sm-12">
<NavTabs
options={[
{
id: ScheduleType.Update,
label: 'Update',
children: <ScheduleDetails disabled={disabled} />,
},
{
id: ScheduleType.Rollback,
label: 'Rollback',
children: <ScheduleDetails disabled={disabled} />,
},
]}
selectedId={value}
onSelect={(value) => setValue(value)}
disabled={disabled}
/>
</div>
</div>
);
}
function ScheduleDetails({ disabled }: Props) {
return (
<div>
<ScheduledTimeField disabled={disabled} />
</div>
);
}
export function typeValidation() {
return number()
.oneOf([ScheduleType.Rollback, ScheduleType.Update])
.default(ScheduleType.Update);
}

View file

@ -0,0 +1,11 @@
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { ScheduleType } from '../types';
export interface FormValues {
name: string;
groupIds: EdgeGroup['Id'][];
type: ScheduleType;
version: string;
time: number;
}

View file

@ -0,0 +1,22 @@
import { array, number, object, SchemaOf, string } from 'yup';
import { EdgeUpdateSchedule } from '../types';
import { nameValidation } from './NameField';
import { FormValues } from './types';
import { typeValidation } from './UpdateTypeTabs';
export function validation(
schedules: EdgeUpdateSchedule[],
currentId?: EdgeUpdateSchedule['id']
): SchemaOf<FormValues> {
return object({
groupIds: array().min(1, 'At least one group is required'),
name: nameValidation(schedules, currentId),
type: typeValidation(),
time: number()
.min(Date.now() / 1000)
.required(),
version: string().required(),
});
}

View file

@ -0,0 +1,3 @@
export { ListView } from './ListView';
export { CreateView } from './CreateView';
export { ItemView } from './ItemView';

View file

@ -0,0 +1,31 @@
import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError, withInvalidate } from '@/react-tools/react-query';
import { EdgeUpdateSchedule } from '../types';
import { FormValues } from '../common/types';
import { queryKeys } from './query-keys';
import { buildUrl } from './urls';
async function create(schedule: FormValues) {
try {
const { data } = await axios.post<EdgeUpdateSchedule>(buildUrl(), schedule);
return data;
} catch (err) {
throw parseAxiosError(
err as Error,
'Failed to create edge update schedule'
);
}
}
export function useCreateMutation() {
const queryClient = useQueryClient();
return useMutation(create, {
...withInvalidate(queryClient, [queryKeys.list()]),
...withError(),
});
}

View file

@ -0,0 +1,24 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EdgeUpdateSchedule } from '../types';
import { queryKeys } from './query-keys';
import { buildUrl } from './urls';
async function getList() {
try {
const { data } = await axios.get<EdgeUpdateSchedule[]>(buildUrl());
return data;
} catch (err) {
throw parseAxiosError(
err as Error,
'Failed to get list of edge update schedules'
);
}
}
export function useList() {
return useQuery(queryKeys.list(), getList);
}

View file

@ -0,0 +1,6 @@
import { EdgeUpdateSchedule } from '../types';
export const queryKeys = {
list: () => ['edge', 'update_schedules'] as const,
item: (id: EdgeUpdateSchedule['id']) => [...queryKeys.list(), id] as const,
};

View file

@ -0,0 +1,7 @@
import { EdgeUpdateSchedule } from '../types';
export const BASE_URL = '/edge_update_schedules';
export function buildUrl(id?: EdgeUpdateSchedule['id']) {
return !id ? BASE_URL : `${BASE_URL}/${id}`;
}

View file

@ -0,0 +1,24 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EdgeUpdateSchedule } from '../types';
import { queryKeys } from './query-keys';
import { buildUrl } from './urls';
export function useItem(id: EdgeUpdateSchedule['id']) {
return useQuery(queryKeys.item(id), () => getItem(id));
}
async function getItem(id: EdgeUpdateSchedule['id']) {
try {
const { data } = await axios.get<EdgeUpdateSchedule>(buildUrl(id));
return data;
} catch (err) {
throw parseAxiosError(
err as Error,
'Failed to get list of edge update schedules'
);
}
}

View file

@ -0,0 +1,42 @@
import { useQueryClient, useMutation } from 'react-query';
import { promiseSequence } from '@/portainer/helpers/promise-utils';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import {
mutationOptions,
withError,
withInvalidate,
} from '@/react-tools/react-query';
import { EdgeUpdateSchedule } from '../types';
import { buildUrl } from './urls';
import { queryKeys } from './query-keys';
export function useRemoveMutation() {
const queryClient = useQueryClient();
return useMutation(
(schedules: EdgeUpdateSchedule[]) =>
promiseSequence(
schedules.map((schedule) => () => deleteUpdateSchedule(schedule.id))
),
mutationOptions(
withInvalidate(queryClient, [queryKeys.list()]),
withError()
)
);
}
async function deleteUpdateSchedule(id: EdgeUpdateSchedule['id']) {
try {
const { data } = await axios.delete<EdgeUpdateSchedule[]>(buildUrl(id));
return data;
} catch (err) {
throw parseAxiosError(
err as Error,
'Failed to delete edge update schedule'
);
}
}

View file

@ -0,0 +1,36 @@
import { useMutation, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError, withInvalidate } from '@/react-tools/react-query';
import { EdgeUpdateSchedule } from '../types';
import { FormValues } from '../common/types';
import { queryKeys } from './query-keys';
import { buildUrl } from './urls';
interface Update {
id: EdgeUpdateSchedule['id'];
values: FormValues;
}
async function update({ id, values }: Update) {
try {
const { data } = await axios.put<EdgeUpdateSchedule>(buildUrl(id), values);
return data;
} catch (err) {
throw parseAxiosError(
err as Error,
'Failed to update edge update schedule'
);
}
}
export function useUpdateMutation() {
const queryClient = useQueryClient();
return useMutation(update, {
...withInvalidate(queryClient, [queryKeys.list()]),
...withError(),
});
}

View file

@ -0,0 +1,31 @@
import { EnvironmentId } from '@/portainer/environments/types';
import { UserId } from '@/portainer/users/types';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
export enum ScheduleType {
Update = 1,
Rollback,
}
export enum StatusType {
Pending,
Failed,
Success,
}
interface Status {
Type: StatusType;
Error: string;
}
export type EdgeUpdateSchedule = {
id: number;
name: string;
time: number;
groupIds: EdgeGroup['Id'][];
type: ScheduleType;
status: { [key: EnvironmentId]: Status };
created: number;
createdBy: UserId;
version: string;
};