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:
parent
dd1662c8b8
commit
6c4c958bf0
61 changed files with 1952 additions and 96 deletions
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CreateView } from './CreateView';
|
|
@ -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) });
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ItemView } from './ItemView';
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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(', ');
|
||||
}
|
|
@ -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,
|
||||
];
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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',
|
||||
};
|
|
@ -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),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ListView } from './ListView';
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export { ListView } from './ListView';
|
||||
export { CreateView } from './CreateView';
|
||||
export { ItemView } from './ItemView';
|
|
@ -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(),
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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}`;
|
||||
}
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
}
|
31
app/react/portainer/environments/update-schedules/types.ts
Normal file
31
app/react/portainer/environments/update-schedules/types.ts
Normal 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;
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue