mirror of
https://github.com/portainer/portainer.git
synced 2025-08-08 23:35:31 +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
32
app/portainer/feature-flags/useRedirectFeatureFlag.ts
Normal file
32
app/portainer/feature-flags/useRedirectFeatureFlag.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { usePublicSettings } from '../settings/queries';
|
||||
|
||||
export enum FeatureFlag {
|
||||
EdgeRemoteUpdate = 'edgeRemoteUpdate',
|
||||
}
|
||||
|
||||
export function useFeatureFlag(
|
||||
flag: FeatureFlag,
|
||||
{ onSuccess }: { onSuccess?: (isEnabled: boolean) => void } = {}
|
||||
) {
|
||||
return usePublicSettings<boolean>({
|
||||
select: (settings) => settings.Features[flag],
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRedirectFeatureFlag(
|
||||
flag: FeatureFlag,
|
||||
to = 'portainer.home'
|
||||
) {
|
||||
const router = useRouter();
|
||||
|
||||
useFeatureFlag(flag, {
|
||||
onSuccess(isEnabled) {
|
||||
if (!isEnabled) {
|
||||
router.stateService.go(to);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
|
@ -28,7 +28,6 @@ export function PublicSettingsViewModel(settings) {
|
|||
this.RequiredPasswordLength = settings.RequiredPasswordLength;
|
||||
this.EnableEdgeComputeFeatures = settings.EnableEdgeComputeFeatures;
|
||||
this.EnforceEdgeID = settings.EnforceEdgeID;
|
||||
this.FeatureFlagSettings = settings.FeatureFlagSettings;
|
||||
this.LogoURL = settings.LogoURL;
|
||||
this.OAuthLoginURI = settings.OAuthLoginURI;
|
||||
this.EnableTelemetry = settings.EnableTelemetry;
|
||||
|
|
|
@ -10,9 +10,14 @@ import {
|
|||
|
||||
import { wizardModule } from './wizard';
|
||||
import { teamsModule } from './teams';
|
||||
import { updateSchedulesModule } from './update-schedules';
|
||||
|
||||
export const viewsModule = angular
|
||||
.module('portainer.app.react.views', [wizardModule, teamsModule])
|
||||
.module('portainer.app.react.views', [
|
||||
wizardModule,
|
||||
teamsModule,
|
||||
updateSchedulesModule,
|
||||
])
|
||||
.component('defaultRegistryName', r2a(DefaultRegistryName, []))
|
||||
.component('defaultRegistryAction', r2a(DefaultRegistryAction, []))
|
||||
.component('defaultRegistryDomain', r2a(DefaultRegistryDomain, []))
|
||||
|
|
48
app/portainer/react/views/update-schedules.ts
Normal file
48
app/portainer/react/views/update-schedules.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import angular from 'angular';
|
||||
import { StateRegistry } from '@uirouter/angularjs';
|
||||
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import {
|
||||
ListView,
|
||||
CreateView,
|
||||
ItemView,
|
||||
} from '@/react/portainer/environments/update-schedules';
|
||||
|
||||
export const updateSchedulesModule = angular
|
||||
.module('portainer.edge.updateSchedules', [])
|
||||
.component('updateSchedulesListView', r2a(ListView, []))
|
||||
.component('updateSchedulesCreateView', r2a(CreateView, []))
|
||||
.component('updateSchedulesItemView', r2a(ItemView, []))
|
||||
.config(config).name;
|
||||
|
||||
function config($stateRegistryProvider: StateRegistry) {
|
||||
$stateRegistryProvider.register({
|
||||
name: 'portainer.endpoints.updateSchedules',
|
||||
url: '/update-schedules',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'updateSchedulesListView',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
$stateRegistryProvider.register({
|
||||
name: 'portainer.endpoints.updateSchedules.create',
|
||||
url: '/update-schedules/new',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'updateSchedulesCreateView',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
$stateRegistryProvider.register({
|
||||
name: 'portainer.endpoints.updateSchedules.item',
|
||||
url: '/update-schedules/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'updateSchedulesItemView',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
|
@ -19,14 +19,17 @@ import { DefaultRegistry, Settings } from './types';
|
|||
export function usePublicSettings<T = PublicSettingsViewModel>({
|
||||
enabled,
|
||||
select,
|
||||
onSuccess,
|
||||
}: {
|
||||
select?: (settings: PublicSettingsViewModel) => T;
|
||||
enabled?: boolean;
|
||||
onSuccess?: (data: T) => void;
|
||||
} = {}) {
|
||||
return useQuery(['settings', 'public'], () => getPublicSettings(), {
|
||||
select,
|
||||
...withError('Unable to retrieve public settings'),
|
||||
enabled,
|
||||
onSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
}
|
||||
|
||||
.parent a {
|
||||
background-color: initial !important;
|
||||
border: 1px solid transparent !important;
|
||||
cursor: inherit !important;
|
||||
background-color: initial;
|
||||
border: 1px solid transparent;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.parent {
|
||||
|
@ -22,11 +22,11 @@
|
|||
}
|
||||
|
||||
.parent a {
|
||||
color: var(--white-color) !important;
|
||||
color: var(--white-color);
|
||||
}
|
||||
:global([theme='dark']) .parent a {
|
||||
color: var(--black-color) !important;
|
||||
color: var(--black-color);
|
||||
}
|
||||
:global([theme='highcontrast']) .parent a {
|
||||
color: var(--black-color) !important;
|
||||
color: var(--black-color);
|
||||
}
|
||||
|
|
|
@ -18,7 +18,11 @@ function Template({ options = [] }: Args) {
|
|||
);
|
||||
|
||||
return (
|
||||
<NavTabs options={options} selectedId={selected} onSelect={setSelected} />
|
||||
<NavTabs
|
||||
options={options}
|
||||
selectedId={selected}
|
||||
onSelect={(value) => setSelected(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -3,19 +3,25 @@ import { ReactNode } from 'react';
|
|||
|
||||
import styles from './NavTabs.module.css';
|
||||
|
||||
export interface Option {
|
||||
export interface Option<T extends string | number = string> {
|
||||
label: string | ReactNode;
|
||||
children?: ReactNode;
|
||||
id: string | number;
|
||||
id: T;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
options: Option[];
|
||||
selectedId?: string | number;
|
||||
onSelect?(id: string | number): void;
|
||||
interface Props<T extends string | number> {
|
||||
options: Option<T>[];
|
||||
selectedId?: T;
|
||||
onSelect?(id: T): void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function NavTabs({ options, selectedId, onSelect = () => {} }: Props) {
|
||||
export function NavTabs<T extends string | number = string>({
|
||||
options,
|
||||
selectedId,
|
||||
onSelect = () => {},
|
||||
disabled,
|
||||
}: Props<T>) {
|
||||
const selected = options.find((option) => option.id === selectedId);
|
||||
|
||||
return (
|
||||
|
@ -52,7 +58,11 @@ export function NavTabs({ options, selectedId, onSelect = () => {} }: Props) {
|
|||
</div>
|
||||
);
|
||||
|
||||
function handleSelect(option: Option) {
|
||||
function handleSelect(option: Option<T>) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.children) {
|
||||
onSelect(option.id);
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import { ReactNode } from 'react';
|
|||
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
import { IconProps } from '@@/Icon';
|
||||
|
||||
import { Table } from './Table';
|
||||
import { multiple } from './filter-types';
|
||||
|
@ -28,7 +29,8 @@ interface DefaultTableSettings
|
|||
|
||||
interface TitleOptionsVisible {
|
||||
title: string;
|
||||
icon?: string;
|
||||
icon?: IconProps['icon'];
|
||||
featherIcon?: IconProps['featherIcon'];
|
||||
hide?: never;
|
||||
}
|
||||
|
||||
|
|
18
app/react/edge/edge-groups/queries/useEdgeGroups.ts
Normal file
18
app/react/edge/edge-groups/queries/useEdgeGroups.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { EdgeGroup } from '../types';
|
||||
|
||||
async function getEdgeGroups() {
|
||||
try {
|
||||
const { data } = await axios.get<EdgeGroup[]>('/edge_groups');
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err as Error, 'Failed fetching edge groups');
|
||||
}
|
||||
}
|
||||
|
||||
export function useEdgeGroups() {
|
||||
return useQuery(['edge', 'groups'], getEdgeGroups);
|
||||
}
|
11
app/react/edge/edge-groups/types.ts
Normal file
11
app/react/edge/edge-groups/types.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import { TagId } from '@/portainer/tags/types';
|
||||
|
||||
export interface EdgeGroup {
|
||||
Id: number;
|
||||
Name: string;
|
||||
Dynamic: boolean;
|
||||
TagIds: TagId[];
|
||||
Endpoints: EnvironmentId[];
|
||||
PartialMatch: boolean;
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -8,6 +8,10 @@ import {
|
|||
} from 'react-feather';
|
||||
|
||||
import { usePublicSettings } from '@/portainer/settings/queries';
|
||||
import {
|
||||
FeatureFlag,
|
||||
useFeatureFlag,
|
||||
} from '@/portainer/feature-flags/useRedirectFeatureFlag';
|
||||
|
||||
import { SidebarItem } from './SidebarItem';
|
||||
import { SidebarSection } from './SidebarSection';
|
||||
|
@ -22,6 +26,10 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
|||
select: (settings) => settings.TeamSync,
|
||||
});
|
||||
|
||||
const isEdgeRemoteUpgradeEnabledQuery = useFeatureFlag(
|
||||
FeatureFlag.EdgeRemoteUpdate
|
||||
);
|
||||
|
||||
const showUsersSection =
|
||||
!window.ddExtension && (isAdmin || (isTeamLeader && !teamSyncQuery.data));
|
||||
|
||||
|
@ -68,6 +76,13 @@ export function SettingsSidebar({ isAdmin, isTeamLeader }: Props) {
|
|||
label="Tags"
|
||||
data-cy="portainerSidebar-environmentTags"
|
||||
/>
|
||||
{isEdgeRemoteUpgradeEnabledQuery.data && (
|
||||
<SidebarItem
|
||||
to="portainer.endpoints.updateSchedules"
|
||||
label="Update & Rollback"
|
||||
data-cy="portainerSidebar-updateSchedules"
|
||||
/>
|
||||
)}
|
||||
</SidebarItem>
|
||||
|
||||
<SidebarItem
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue