mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
feat(edge/jobs): migrate item view to react [EE-2220] (#11887)
Some checks failed
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
ci / build_images (map[arch:arm platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:s390x platform:linux version:]) (push) Has been cancelled
/ triage (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Test / test-client (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:linux]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
Test / test-server (map[arch:arm64 platform:linux]) (push) Has been cancelled
ci / build_manifests (push) Has been cancelled
Some checks failed
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
ci / build_images (map[arch:arm platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:s390x platform:linux version:]) (push) Has been cancelled
/ triage (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Test / test-client (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:linux]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
Test / test-server (map[arch:arm64 platform:linux]) (push) Has been cancelled
ci / build_manifests (push) Has been cancelled
This commit is contained in:
parent
62c2bf86aa
commit
eb6d251a73
44 changed files with 778 additions and 886 deletions
|
@ -75,8 +75,6 @@ export function NavTabs<T extends string | number = string>({
|
|||
return;
|
||||
}
|
||||
|
||||
if (option.children) {
|
||||
onSelect(option.id);
|
||||
}
|
||||
onSelect(option.id);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { Form, Formik, useFormikContext } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import moment from 'moment';
|
||||
|
||||
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
|
||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||
|
@ -14,15 +13,19 @@ import { WebEditorForm } from '@@/WebEditorForm';
|
|||
import { FileUploadForm } from '@@/form-components/FileUpload';
|
||||
|
||||
import { NameField } from '../components/EdgeJobForm/NameField';
|
||||
import { FormValues } from '../components/EdgeJobForm/types';
|
||||
import { useValidation } from '../components/EdgeJobForm/useValidation';
|
||||
import { JobConfigurationFieldset } from '../components/EdgeJobForm/JobConfigurationFieldset';
|
||||
import {
|
||||
BasePayload,
|
||||
CreateEdgeJobPayload,
|
||||
useCreateEdgeJobMutation,
|
||||
} from '../queries/useCreateEdgeJobMutation/useCreateEdgeJobMutation';
|
||||
import { defaultCronExpression } from '../components/EdgeJobForm/RecurringFieldset';
|
||||
import {
|
||||
toRecurringRequest,
|
||||
toRecurringViewModel,
|
||||
} from '../components/EdgeJobForm/parseRecurringValues';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { useValidation } from './useValidation';
|
||||
|
||||
export function CreateEdgeJobForm() {
|
||||
const mutation = useCreateEdgeJobMutation();
|
||||
|
@ -35,16 +38,12 @@ export function CreateEdgeJobForm() {
|
|||
validateOnMount
|
||||
initialValues={{
|
||||
name: '',
|
||||
recurring: false,
|
||||
cronExpression: '',
|
||||
recurringOption: defaultCronExpression,
|
||||
method: 'editor',
|
||||
cronMethod: 'basic',
|
||||
dateTime: new Date(),
|
||||
edgeGroupIds: [],
|
||||
environmentIds: [],
|
||||
file: undefined,
|
||||
fileContent: '',
|
||||
...toRecurringViewModel(),
|
||||
}}
|
||||
onSubmit={(values) => {
|
||||
mutation.mutate(getPayload(values.method, values), {
|
||||
|
@ -122,6 +121,7 @@ function InnerForm({ isLoading }: { isLoading: boolean }) {
|
|||
isValid={isValid}
|
||||
data-cy="edgeJobCreate-addJobButton"
|
||||
loadingText="In progress..."
|
||||
errors={errors}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
|
@ -162,42 +162,7 @@ function getPayload(
|
|||
name: values.name,
|
||||
edgeGroups: values.edgeGroupIds,
|
||||
endpoints: values.environmentIds,
|
||||
...getRecurringConfig(values),
|
||||
...toRecurringRequest(values),
|
||||
};
|
||||
}
|
||||
|
||||
function getRecurringConfig(values: FormValues): {
|
||||
recurring: boolean;
|
||||
cronExpression: string;
|
||||
} {
|
||||
if (values.cronMethod !== 'basic') {
|
||||
return {
|
||||
recurring: true,
|
||||
cronExpression: values.cronExpression,
|
||||
};
|
||||
}
|
||||
|
||||
if (values.recurring) {
|
||||
return {
|
||||
recurring: true,
|
||||
cronExpression: values.recurringOption,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
recurring: false,
|
||||
cronExpression: dateTimeToCron(values.dateTime),
|
||||
};
|
||||
|
||||
function dateTimeToCron(datetime: Date) {
|
||||
const date = moment(datetime);
|
||||
return [
|
||||
date.minutes(),
|
||||
date.hours(),
|
||||
date.date(),
|
||||
date.month() + 1,
|
||||
'*',
|
||||
].join(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
import { Calendar, Edit } from 'lucide-react';
|
||||
|
||||
import { BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
export const cronMethodOptions: ReadonlyArray<BoxSelectorOption<string>> = [
|
||||
{
|
||||
id: 'config_basic',
|
||||
value: 'basic',
|
||||
icon: Calendar,
|
||||
iconType: 'badge',
|
||||
label: 'Basic configuration',
|
||||
description: 'Select date from calendar',
|
||||
},
|
||||
{
|
||||
id: 'config_advanced',
|
||||
value: 'advanced',
|
||||
icon: Edit,
|
||||
iconType: 'badge',
|
||||
label: 'Advanced configuration',
|
||||
description: 'Write your own cron rule',
|
||||
},
|
||||
] as const;
|
|
@ -1,7 +1,7 @@
|
|||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { timeOptions } from './RecurringFieldset';
|
||||
import { timeOptions } from '../components/EdgeJobForm/RecurringFieldset';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
|
@ -12,17 +12,14 @@ import { useMemo } from 'react';
|
|||
|
||||
import { file } from '@@/form-components/yup-file-validation';
|
||||
|
||||
import { EdgeJob } from '../../types';
|
||||
import { useNameValidation } from '../components/EdgeJobForm/NameField';
|
||||
import { cronValidation } from '../components/EdgeJobForm/AdvancedCronFieldset';
|
||||
import { timeOptions } from '../components/EdgeJobForm/RecurringFieldset';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { useNameValidation } from './NameField';
|
||||
import { cronValidation } from './AdvancedCronFieldset';
|
||||
import { timeOptions } from './RecurringFieldset';
|
||||
|
||||
export function useValidation({
|
||||
id,
|
||||
}: { id?: EdgeJob['Id'] } = {}): SchemaOf<FormValues> {
|
||||
const nameValidation = useNameValidation(id);
|
||||
export function useValidation(): SchemaOf<FormValues> {
|
||||
const nameValidation = useNameValidation();
|
||||
return useMemo(
|
||||
() =>
|
||||
object({
|
75
app/react/edge/edge-jobs/ItemView/ItemView.tsx
Normal file
75
app/react/edge/edge-jobs/ItemView/ItemView.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { ListIcon, WrenchIcon } from 'lucide-react';
|
||||
|
||||
import { useIdParam } from '@/react/hooks/useIdParam';
|
||||
import { useParamState } from '@/react/hooks/useParamState';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { NavTabs } from '@@/NavTabs';
|
||||
|
||||
import { useEdgeJob } from '../queries/useEdgeJob';
|
||||
|
||||
import { UpdateEdgeJobForm } from './UpdateEdgeJobForm/UpdateEdgeJobForm';
|
||||
import { ResultsDatatable } from './ResultsDatatable/ResultsDatatable';
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 0,
|
||||
label: 'Configuration',
|
||||
icon: WrenchIcon,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
label: 'Results',
|
||||
icon: ListIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function ItemView() {
|
||||
const id = useIdParam();
|
||||
|
||||
const [tabId = 0, setTabId] = useParamState('tab', (param) =>
|
||||
param ? parseInt(param, 10) : 0
|
||||
);
|
||||
|
||||
const edgeJobQuery = useEdgeJob(id);
|
||||
|
||||
if (!edgeJobQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const edgeJob = edgeJobQuery.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Edge job details"
|
||||
breadcrumbs={[{ label: 'Edge jobs', link: 'edge.jobs' }, edgeJob.Name]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Body>
|
||||
<NavTabs
|
||||
selectedId={tabId}
|
||||
onSelect={(id) => {
|
||||
setTabId(id);
|
||||
}}
|
||||
options={tabs}
|
||||
/>
|
||||
|
||||
{tabId === tabs[0].id && <UpdateEdgeJobForm edgeJob={edgeJob} />}
|
||||
|
||||
{tabId === tabs[1].id && (
|
||||
<div className="mt-4">
|
||||
<ResultsDatatable jobId={edgeJob.Id} />
|
||||
</div>
|
||||
)}
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,67 +1,93 @@
|
|||
import { List } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { withMeta } from '@@/datatables/extend-options/withMeta';
|
||||
import { useRepeater } from '@@/datatables/useRepeater';
|
||||
|
||||
import { LogsStatus } from '../../types';
|
||||
import { EdgeJob, JobResult, LogsStatus } from '../../types';
|
||||
import { useJobResults } from '../../queries/jobResults/useJobResults';
|
||||
|
||||
import { DecoratedJobResult } from './types';
|
||||
import { columns } from './columns';
|
||||
import { createStore } from './datatable-store';
|
||||
|
||||
const tableKey = 'edge-job-results';
|
||||
const store = createStore(tableKey);
|
||||
|
||||
export function ResultsDatatable({
|
||||
dataset,
|
||||
onCollectLogs,
|
||||
onClearLogs,
|
||||
onDownloadLogs,
|
||||
onRefresh,
|
||||
}: {
|
||||
dataset: Array<DecoratedJobResult>;
|
||||
|
||||
onCollectLogs(envId: EnvironmentId): void;
|
||||
onDownloadLogs(envId: EnvironmentId): void;
|
||||
onClearLogs(envId: EnvironmentId): void;
|
||||
onRefresh(): void;
|
||||
}) {
|
||||
const anyCollecting = dataset.some(
|
||||
(r) => r.LogsStatus === LogsStatus.Pending
|
||||
);
|
||||
export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) {
|
||||
const tableState = useTableState(store, tableKey);
|
||||
|
||||
const { setAutoRefreshRate } = tableState;
|
||||
const jobResultsQuery = useJobResults(jobId, {
|
||||
refetchInterval(dataset) {
|
||||
const anyCollecting = dataset?.some(
|
||||
(r) => r.LogsStatus === LogsStatus.Pending
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAutoRefreshRate(anyCollecting ? 5 : 0);
|
||||
}, [anyCollecting, setAutoRefreshRate]);
|
||||
if (anyCollecting) {
|
||||
return 5000;
|
||||
}
|
||||
|
||||
return tableState.autoRefreshRate * 1000;
|
||||
},
|
||||
});
|
||||
|
||||
const environmentIds = jobResultsQuery.data?.map(
|
||||
(result) => result.EndpointId
|
||||
);
|
||||
|
||||
const environmentsQuery = useEnvironmentList(
|
||||
{ endpointIds: environmentIds },
|
||||
{ enabled: !!environmentIds && !jobResultsQuery.isLoading }
|
||||
);
|
||||
|
||||
const dataset = useMemo(
|
||||
() =>
|
||||
jobResultsQuery.isLoading || environmentsQuery.isLoading
|
||||
? []
|
||||
: associateEndpointsToResults(
|
||||
jobResultsQuery.data || [],
|
||||
environmentsQuery.environments
|
||||
),
|
||||
[
|
||||
environmentsQuery.environments,
|
||||
environmentsQuery.isLoading,
|
||||
jobResultsQuery.data,
|
||||
jobResultsQuery.isLoading,
|
||||
]
|
||||
);
|
||||
|
||||
useRepeater(tableState.autoRefreshRate, onRefresh);
|
||||
return (
|
||||
<Datatable
|
||||
disableSelect
|
||||
columns={columns}
|
||||
dataset={dataset}
|
||||
isLoading={jobResultsQuery.isLoading || environmentsQuery.isLoading}
|
||||
title="Results"
|
||||
titleIcon={List}
|
||||
settingsManager={tableState}
|
||||
extendTableOptions={withMeta({
|
||||
table: 'edge-job-results',
|
||||
collectLogs: handleCollectLogs,
|
||||
downloadLogs: onDownloadLogs,
|
||||
clearLogs: onClearLogs,
|
||||
jobId,
|
||||
})}
|
||||
data-cy="edge-job-results-datatable"
|
||||
/>
|
||||
);
|
||||
|
||||
function handleCollectLogs(envId: EnvironmentId) {
|
||||
onCollectLogs(envId);
|
||||
}
|
||||
}
|
||||
|
||||
function associateEndpointsToResults(
|
||||
results: Array<JobResult>,
|
||||
environments: Array<Environment>
|
||||
) {
|
||||
return results.map((result) => {
|
||||
const environment = environments.find(
|
||||
(environment) => environment.Id === result.EndpointId
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
Endpoint: environment,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -3,6 +3,9 @@ import { CellContext, createColumnHelper } from '@tanstack/react-table';
|
|||
import { Button } from '@@/buttons';
|
||||
|
||||
import { LogsStatus } from '../../types';
|
||||
import { useDownloadLogsMutation } from '../../queries/jobResults/useDownloadLogsMutation';
|
||||
import { useClearLogsMutation } from '../../queries/jobResults/useClearLogsMutation';
|
||||
import { useCollectLogsMutation } from '../../queries/jobResults/useCollectLogsMutation';
|
||||
|
||||
import { DecoratedJobResult, getTableMeta } from './types';
|
||||
|
||||
|
@ -29,6 +32,11 @@ function ActionsCell({
|
|||
table,
|
||||
}: CellContext<DecoratedJobResult, unknown>) {
|
||||
const tableMeta = getTableMeta(table.options.meta);
|
||||
const id = tableMeta.jobId;
|
||||
|
||||
const downloadLogsMutation = useDownloadLogsMutation(id);
|
||||
const clearLogsMutations = useClearLogsMutation(id);
|
||||
const collectLogsMutation = useCollectLogsMutation(id);
|
||||
|
||||
switch (item.LogsStatus) {
|
||||
case LogsStatus.Pending:
|
||||
|
@ -42,14 +50,14 @@ function ActionsCell({
|
|||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => tableMeta.downloadLogs(item.EndpointId)}
|
||||
data-cy={`edge-job-download-logs-${item.Endpoint.Name}`}
|
||||
onClick={() => downloadLogsMutation.mutate(item.EndpointId)}
|
||||
data-cy={`edge-job-download-logs-${item.Endpoint?.Name}`}
|
||||
>
|
||||
Download logs
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => tableMeta.clearLogs(item.EndpointId)}
|
||||
data-cy={`edge-job-clear-logs-${item.Endpoint.Name}`}
|
||||
onClick={() => clearLogsMutations.mutate(item.EndpointId)}
|
||||
data-cy={`edge-job-clear-logs-${item.Endpoint?.Name}`}
|
||||
>
|
||||
Clear logs
|
||||
</Button>
|
||||
|
@ -59,8 +67,8 @@ function ActionsCell({
|
|||
default:
|
||||
return (
|
||||
<Button
|
||||
onClick={() => tableMeta.collectLogs(item.EndpointId)}
|
||||
data-cy={`edge-job-retrieve-logs-${item.Endpoint.Name}`}
|
||||
onClick={() => collectLogsMutation.mutate(item.EndpointId)}
|
||||
data-cy={`edge-job-retrieve-logs-${item.Endpoint?.Name}`}
|
||||
>
|
||||
Retrieve logs
|
||||
</Button>
|
||||
|
|
|
@ -1,19 +1,14 @@
|
|||
import {
|
||||
Environment,
|
||||
EnvironmentId,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
import { JobResult } from '../../types';
|
||||
import { EdgeJob, JobResult } from '../../types';
|
||||
|
||||
export interface DecoratedJobResult extends JobResult {
|
||||
Endpoint: Environment;
|
||||
Endpoint?: Environment;
|
||||
}
|
||||
|
||||
interface TableMeta {
|
||||
table: 'edge-job-results';
|
||||
collectLogs(envId: EnvironmentId): void;
|
||||
downloadLogs(envId: EnvironmentId): void;
|
||||
clearLogs(envId: EnvironmentId): void;
|
||||
jobId: EdgeJob['Id'];
|
||||
}
|
||||
|
||||
function isTableMeta(meta: unknown): meta is TableMeta {
|
||||
|
|
|
@ -0,0 +1,123 @@
|
|||
import { Form, Formik, useFormikContext } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
|
||||
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { WebEditorForm } from '@@/WebEditorForm';
|
||||
|
||||
import { NameField } from '../../components/EdgeJobForm/NameField';
|
||||
import { JobConfigurationFieldset } from '../../components/EdgeJobForm/JobConfigurationFieldset';
|
||||
import {
|
||||
UpdatePayload,
|
||||
useUpdateEdgeJobMutation,
|
||||
} from '../../queries/useUpdateEdgeJobMutation';
|
||||
import {
|
||||
toRecurringRequest,
|
||||
toRecurringViewModel,
|
||||
} from '../../components/EdgeJobForm/parseRecurringValues';
|
||||
import { EdgeJobResponse } from '../../queries/useEdgeJob';
|
||||
import { useEdgeJobFile } from '../../queries/useEdgeJobFile';
|
||||
import { useValidation } from '../useValidation';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function UpdateEdgeJobForm({ edgeJob }: { edgeJob: EdgeJobResponse }) {
|
||||
const fileQuery = useEdgeJobFile(edgeJob.Id);
|
||||
const mutation = useUpdateEdgeJobMutation();
|
||||
const validation = useValidation({ id: edgeJob.Id });
|
||||
const router = useRouter();
|
||||
|
||||
if (!fileQuery.isSuccess) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik<FormValues>
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
initialValues={{
|
||||
name: edgeJob.Name,
|
||||
|
||||
edgeGroupIds: edgeJob.EdgeGroups || [],
|
||||
environmentIds: edgeJob.Endpoints || [],
|
||||
fileContent: fileQuery.data,
|
||||
...toRecurringViewModel({
|
||||
cronExpression: edgeJob.CronExpression,
|
||||
recurring: edgeJob.Recurring,
|
||||
}),
|
||||
}}
|
||||
onSubmit={(values) => {
|
||||
mutation.mutate(
|
||||
{ id: edgeJob.Id, payload: getPayload(values) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Edge job successfully updated');
|
||||
router.stateService.go('^');
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
<InnerForm isLoading={mutation.isLoading} />
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerForm({ isLoading }: { isLoading: boolean }) {
|
||||
const { values, setFieldValue, isValid, errors } =
|
||||
useFormikContext<FormValues>();
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<NameField errors={errors.name} />
|
||||
|
||||
<JobConfigurationFieldset />
|
||||
|
||||
<WebEditorForm
|
||||
data-cy="edge-job-editor"
|
||||
id="edge-job-editor"
|
||||
onChange={(value) => setFieldValue('fileContent', value)}
|
||||
value={values.fileContent}
|
||||
placeholder="Define or paste the content of your script file here"
|
||||
shell
|
||||
error={errors.fileContent}
|
||||
/>
|
||||
|
||||
<EdgeGroupsSelector
|
||||
onChange={(value) => setFieldValue('edgeGroupIds', value)}
|
||||
value={values.edgeGroupIds}
|
||||
error={errors.edgeGroupIds}
|
||||
/>
|
||||
|
||||
<FormSection title="Target environments">
|
||||
<AssociatedEdgeEnvironmentsSelector
|
||||
onChange={(value) => setFieldValue('environmentIds', value)}
|
||||
value={values.environmentIds}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormActions
|
||||
submitLabel="Update edge job"
|
||||
isLoading={isLoading}
|
||||
isValid={isValid}
|
||||
data-cy="updateJobButton"
|
||||
loadingText="In progress..."
|
||||
errors={errors}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function getPayload(values: FormValues): UpdatePayload {
|
||||
return {
|
||||
name: values.name,
|
||||
edgeGroups: values.edgeGroupIds,
|
||||
endpoints: values.environmentIds,
|
||||
fileContent: values.fileContent,
|
||||
...toRecurringRequest(values),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { UpdatePayload } from '../../queries/useUpdateEdgeJobMutation';
|
||||
import { toRecurringRequest } from '../../components/EdgeJobForm/parseRecurringValues';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function getPayload(values: FormValues): UpdatePayload {
|
||||
return {
|
||||
name: values.name,
|
||||
edgeGroups: values.edgeGroupIds,
|
||||
endpoints: values.environmentIds,
|
||||
fileContent: values.fileContent,
|
||||
...toRecurringRequest(values),
|
||||
};
|
||||
}
|
18
app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/types.ts
Normal file
18
app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/types.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { timeOptions } from '../../components/EdgeJobForm/RecurringFieldset';
|
||||
|
||||
export interface FormValues {
|
||||
name: string;
|
||||
recurring: boolean;
|
||||
edgeGroupIds: Array<EdgeGroup['Id']>;
|
||||
environmentIds: Array<EnvironmentId>;
|
||||
|
||||
fileContent: string;
|
||||
|
||||
cronMethod: 'basic' | 'advanced';
|
||||
dateTime: Date; // basic !recurring
|
||||
recurringOption: (typeof timeOptions)[number]['value']; // basic recurring
|
||||
cronExpression: string; // advanced
|
||||
}
|
60
app/react/edge/edge-jobs/ItemView/useValidation.ts
Normal file
60
app/react/edge/edge-jobs/ItemView/useValidation.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import {
|
||||
SchemaOf,
|
||||
array,
|
||||
boolean,
|
||||
date,
|
||||
mixed,
|
||||
number,
|
||||
object,
|
||||
string,
|
||||
} from 'yup';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { EdgeJob } from '../types';
|
||||
import { useNameValidation } from '../components/EdgeJobForm/NameField';
|
||||
import { cronValidation } from '../components/EdgeJobForm/AdvancedCronFieldset';
|
||||
import { timeOptions } from '../components/EdgeJobForm/RecurringFieldset';
|
||||
|
||||
import { FormValues } from './UpdateEdgeJobForm/types';
|
||||
|
||||
export function useValidation({
|
||||
id,
|
||||
}: {
|
||||
id: EdgeJob['Id'];
|
||||
}): SchemaOf<FormValues> {
|
||||
const nameValidation = useNameValidation(id);
|
||||
return useMemo(
|
||||
() =>
|
||||
object({
|
||||
name: nameValidation,
|
||||
recurring: boolean().default(false),
|
||||
cronExpression: string().default('').when('cronMethod', {
|
||||
is: 'advanced',
|
||||
then: cronValidation().required(),
|
||||
}),
|
||||
edgeGroupIds: array(number().required()),
|
||||
environmentIds: array(number().required()),
|
||||
|
||||
fileContent: string().required('This field is required.'),
|
||||
|
||||
cronMethod: mixed<'basic' | 'advanced'>()
|
||||
.oneOf(['basic', 'advanced'])
|
||||
.default('basic'),
|
||||
dateTime: date()
|
||||
.default(new Date())
|
||||
.when(['recurring', 'cronMethod'], {
|
||||
is: (recurring: boolean, cronMethod: 'basic' | 'advanced') =>
|
||||
!recurring && cronMethod === 'basic',
|
||||
then: (schema) => schema.required('This field is required.'),
|
||||
}),
|
||||
recurringOption: mixed()
|
||||
.oneOf(timeOptions.map((o) => o.value))
|
||||
.when(['recurring', 'cronMethod'], {
|
||||
is: (recurring: boolean, cronMethod: 'basic' | 'advanced') =>
|
||||
recurring && cronMethod === 'basic',
|
||||
then: (schema) => schema.required('This field is required.'),
|
||||
}),
|
||||
}),
|
||||
[nameValidation]
|
||||
);
|
||||
}
|
|
@ -2,7 +2,8 @@ import { useFormikContext } from 'formik';
|
|||
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { FormValues } from '../../CreateView/types';
|
||||
|
||||
import { RecurringFieldset, defaultCronExpression } from './RecurringFieldset';
|
||||
import { ScheduledDateFieldset } from './ScheduledDateFieldset';
|
||||
|
||||
|
|
|
@ -1,14 +1,33 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
import { Calendar, Edit } from 'lucide-react';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { BoxSelector } from '@@/BoxSelector';
|
||||
import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector';
|
||||
|
||||
import { cronMethodOptions } from '../../CreateView/cron-method-options';
|
||||
import { FormValues } from '../../CreateView/types';
|
||||
|
||||
import { FormValues } from './types';
|
||||
import { AdvancedCronFieldset } from './AdvancedCronFieldset';
|
||||
import { BasicCronFieldset } from './BasicCronFieldset';
|
||||
|
||||
export const cronMethodOptions: ReadonlyArray<BoxSelectorOption<string>> = [
|
||||
{
|
||||
id: 'config_basic',
|
||||
value: 'basic',
|
||||
icon: Calendar,
|
||||
iconType: 'badge',
|
||||
label: 'Basic configuration',
|
||||
description: 'Select date from calendar',
|
||||
},
|
||||
{
|
||||
id: 'config_advanced',
|
||||
value: 'advanced',
|
||||
icon: Edit,
|
||||
iconType: 'badge',
|
||||
label: 'Advanced configuration',
|
||||
description: 'Write your own cron rule',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function JobConfigurationFieldset() {
|
||||
const { values, setFieldValue } = useFormikContext<FormValues>();
|
||||
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import { addHours, getDate, getHours, getMinutes, getMonth } from 'date-fns';
|
||||
import moment from 'moment';
|
||||
|
||||
import { defaultCronExpression, timeOptions } from './RecurringFieldset';
|
||||
|
||||
interface RecurringViewModel {
|
||||
cronMethod: 'basic' | 'advanced';
|
||||
cronExpression: string;
|
||||
recurring: boolean;
|
||||
recurringOption: (typeof timeOptions)[number]['value'];
|
||||
dateTime: Date;
|
||||
}
|
||||
|
||||
interface RecurringRequestModel {
|
||||
recurring: boolean;
|
||||
cronExpression: string;
|
||||
}
|
||||
|
||||
export function toRecurringRequest(
|
||||
values: RecurringViewModel
|
||||
): RecurringRequestModel {
|
||||
if (values.cronMethod !== 'basic') {
|
||||
return {
|
||||
recurring: true,
|
||||
cronExpression: values.cronExpression,
|
||||
};
|
||||
}
|
||||
|
||||
if (values.recurring) {
|
||||
return {
|
||||
recurring: true,
|
||||
cronExpression: values.recurringOption,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
recurring: false,
|
||||
cronExpression: dateTimeToCron(values.dateTime),
|
||||
};
|
||||
|
||||
function dateTimeToCron(date: Date) {
|
||||
return [
|
||||
getMinutes(date),
|
||||
getHours(date),
|
||||
getDate(date),
|
||||
getMonth(date) + 1,
|
||||
'*',
|
||||
].join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
export function toRecurringViewModel(
|
||||
{ cronExpression, recurring }: RecurringRequestModel = {
|
||||
cronExpression: defaultCronExpression,
|
||||
recurring: true,
|
||||
}
|
||||
): RecurringViewModel {
|
||||
const defaultTime = addHours(new Date(), 1);
|
||||
const scheduled = timeOptions.find((v) => v.value === cronExpression);
|
||||
|
||||
return {
|
||||
recurring,
|
||||
cronExpression,
|
||||
recurringOption: scheduled?.value || defaultCronExpression,
|
||||
cronMethod: recurring && !scheduled ? 'advanced' : 'basic',
|
||||
dateTime: cronExpression
|
||||
? cronToDateTime(cronExpression, defaultTime)
|
||||
: defaultTime,
|
||||
};
|
||||
}
|
||||
|
||||
function cronToDateTime(cron: string, defaultTime: Date): Date {
|
||||
const strings = cron.split(' ');
|
||||
if (strings.length > 4) {
|
||||
return moment(cron, 'm H D M').toDate();
|
||||
}
|
||||
|
||||
return defaultTime;
|
||||
}
|
22
app/react/edge/edge-jobs/queries/jobResults/build-url.ts
Normal file
22
app/react/edge/edge-jobs/queries/jobResults/build-url.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { EdgeJob } from '../../types';
|
||||
import { buildUrl as buildEdgeJobUrl } from '../build-url';
|
||||
|
||||
export function buildUrl({
|
||||
action,
|
||||
id,
|
||||
taskId,
|
||||
}: {
|
||||
id: EdgeJob['Id'];
|
||||
action?: 'logs';
|
||||
taskId?: EnvironmentId;
|
||||
}) {
|
||||
const baseUrl = buildEdgeJobUrl({ id, action: 'tasks' });
|
||||
|
||||
if (taskId) {
|
||||
return `${baseUrl}/${taskId}/${action}`;
|
||||
}
|
||||
|
||||
return baseUrl;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { EdgeJob } from '../../types';
|
||||
import { queryKeys as edgeJobQueryKeys } from '../query-keys';
|
||||
|
||||
export const queryKeys = {
|
||||
base: (id: EdgeJob['Id']) =>
|
||||
[...edgeJobQueryKeys.item(id), 'results'] as const,
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withInvalidate } from '@/react-tools/react-query';
|
||||
|
||||
import { EdgeJob } from '../../types';
|
||||
|
||||
import { buildUrl } from './build-url';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useClearLogsMutation(id: EdgeJob['Id']) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (environmentId: EnvironmentId) =>
|
||||
clearLogsMutation(id, environmentId),
|
||||
...withInvalidate(queryClient, [queryKeys.base(id)]),
|
||||
});
|
||||
}
|
||||
|
||||
async function clearLogsMutation(
|
||||
id: EdgeJob['Id'],
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
try {
|
||||
await axios.delete(buildUrl({ id, action: 'logs', taskId: environmentId }));
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Failed clearing edge job result logs');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withInvalidate } from '@/react-tools/react-query';
|
||||
|
||||
import { EdgeJob } from '../../types';
|
||||
|
||||
import { buildUrl } from './build-url';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useCollectLogsMutation(id: EdgeJob['Id']) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (environmentId: EnvironmentId) =>
|
||||
collectLogsMutation(id, environmentId),
|
||||
...withInvalidate(queryClient, [queryKeys.base(id)]),
|
||||
});
|
||||
}
|
||||
|
||||
async function collectLogsMutation(
|
||||
id: EdgeJob['Id'],
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
try {
|
||||
await axios.post(buildUrl({ id, action: 'logs', taskId: environmentId }));
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Unable to collect logs');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withInvalidate } from '@/react-tools/react-query';
|
||||
|
||||
import { EdgeJob } from '../../types';
|
||||
|
||||
import { buildUrl } from './build-url';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useDownloadLogsMutation(id: EdgeJob['Id']) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (environmentId: EnvironmentId) =>
|
||||
downloadLogsMutation(id, environmentId),
|
||||
...withInvalidate(queryClient, [queryKeys.base(id)]),
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadLogsMutation(
|
||||
id: EdgeJob['Id'],
|
||||
environmentId: EnvironmentId
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<{ FileContent: string }>(
|
||||
buildUrl({ id, action: 'logs', taskId: environmentId })
|
||||
);
|
||||
const downloadData = new Blob([data.FileContent], {
|
||||
type: 'text/plain;charset=utf-8',
|
||||
});
|
||||
const logFileName = `job_${id}_task_${environmentId}.log`;
|
||||
saveAs(downloadData, logFileName);
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Unable to download file');
|
||||
}
|
||||
}
|
36
app/react/edge/edge-jobs/queries/jobResults/useJobResults.ts
Normal file
36
app/react/edge/edge-jobs/queries/jobResults/useJobResults.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { EdgeJob, JobResult } from '../../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
import { buildUrl } from './build-url';
|
||||
|
||||
export function useJobResults(
|
||||
id: EdgeJob['Id'],
|
||||
{
|
||||
refetchInterval,
|
||||
}: {
|
||||
refetchInterval?:
|
||||
| number
|
||||
| false
|
||||
| ((data: Array<JobResult> | undefined) => number | false);
|
||||
} = {}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.base(id),
|
||||
queryFn: () => getJobResults(id),
|
||||
refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
async function getJobResults(id: EdgeJob['Id']) {
|
||||
try {
|
||||
const { data } = await axios.get<Array<JobResult>>(buildUrl({ id }));
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Failed fetching edge job results');
|
||||
}
|
||||
}
|
|
@ -1,3 +1,7 @@
|
|||
import { EdgeJob } from '../types';
|
||||
|
||||
export const queryKeys = {
|
||||
base: () => ['edge', 'jobs'] as const,
|
||||
item: (id: EdgeJob['Id']) => [...queryKeys.base(), id] as const,
|
||||
file: (id: EdgeJob['Id']) => [...queryKeys.item(id), 'file'] as const,
|
||||
};
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withInvalidate } from '@/react-tools/react-query';
|
||||
|
||||
import { queryKeys } from '../query-keys';
|
||||
|
||||
import { createJobFromFile } from './createJobFromFile';
|
||||
import { createJobFromFileContent } from './createJobFromFileContent';
|
||||
|
||||
export function useCreateEdgeJobMutation() {
|
||||
return useMutation(createEdgeJob);
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createEdgeJob,
|
||||
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||
});
|
||||
}
|
||||
|
||||
export type BasePayload = {
|
||||
|
|
33
app/react/edge/edge-jobs/queries/useEdgeJob.ts
Normal file
33
app/react/edge/edge-jobs/queries/useEdgeJob.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { EdgeJob } from '../types';
|
||||
|
||||
import { buildUrl } from './build-url';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export interface EdgeJobResponse extends Omit<EdgeJob, 'Endpoints'> {
|
||||
Endpoints: Array<EnvironmentId> | null;
|
||||
}
|
||||
|
||||
async function getEdgeJob(id: EdgeJobResponse['Id']) {
|
||||
try {
|
||||
const { data } = await axios.get<EdgeJobResponse>(buildUrl({ id }));
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Failed fetching edge job');
|
||||
}
|
||||
}
|
||||
|
||||
export function useEdgeJob<T = EdgeJobResponse>(
|
||||
id: EdgeJobResponse['Id'],
|
||||
{
|
||||
select,
|
||||
}: {
|
||||
select?: (job: EdgeJobResponse) => T;
|
||||
} = {}
|
||||
) {
|
||||
return useQuery(queryKeys.item(id), () => getEdgeJob(id), { select });
|
||||
}
|
28
app/react/edge/edge-jobs/queries/useEdgeJobFile.ts
Normal file
28
app/react/edge/edge-jobs/queries/useEdgeJobFile.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { EdgeJob } from '../types';
|
||||
|
||||
import { buildUrl } from './build-url';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export interface EdgeJobResponse extends Omit<EdgeJob, 'Endpoints'> {
|
||||
Endpoints: Array<EnvironmentId>;
|
||||
}
|
||||
|
||||
async function getEdgeJobFile(id: EdgeJobResponse['Id']) {
|
||||
try {
|
||||
const { data } = await axios.get<{ FileContent: string }>(
|
||||
buildUrl({ id, action: 'file' })
|
||||
);
|
||||
return data.FileContent;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err, 'Failed fetching edge job file');
|
||||
}
|
||||
}
|
||||
|
||||
export function useEdgeJobFile(id: EdgeJobResponse['Id']) {
|
||||
return useQuery(queryKeys.file(id), () => getEdgeJobFile(id));
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withInvalidate } from '@/react-tools/react-query';
|
||||
|
||||
import { EdgeGroup } from '../../edge-groups/types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export interface UpdatePayload {
|
||||
name?: string;
|
||||
cronExpression?: string;
|
||||
recurring?: boolean;
|
||||
endpoints?: Array<EnvironmentId>;
|
||||
edgeGroups?: Array<EdgeGroup['Id']>;
|
||||
fileContent?: string;
|
||||
}
|
||||
|
||||
export function useUpdateEdgeJobMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: updateEdgeJob,
|
||||
...withInvalidate(queryClient, [queryKeys.base()]),
|
||||
});
|
||||
}
|
||||
|
||||
async function updateEdgeJob({
|
||||
id,
|
||||
payload,
|
||||
}: {
|
||||
id: number;
|
||||
payload: UpdatePayload;
|
||||
}) {
|
||||
try {
|
||||
await axios.put(`/edge_jobs/${id}`, payload);
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error);
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ export interface EdgeJob {
|
|||
Created: number;
|
||||
CronExpression: string;
|
||||
Endpoints: Record<EnvironmentId, EndpointMeta>;
|
||||
EdgeGroups: number[];
|
||||
EdgeGroups: number[] | null;
|
||||
Name: string;
|
||||
ScriptPath: string;
|
||||
Recurring: boolean;
|
||||
|
|
|
@ -14,7 +14,7 @@ export function useParamState<T>(
|
|||
return [
|
||||
state,
|
||||
(value?: T) => {
|
||||
router.stateService.go('.', { [param]: value }, {});
|
||||
router.stateService.go('.', { [param]: value }, { reload: false });
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue