mirror of
https://github.com/inventree/InvenTree.git
synced 2026-04-04 10:31:03 +00:00
[API] Monitor task (#11527)
* Enhance docstring * Return the ID of an offloaded task * Add API endpoint for background task detail * Add UI hook for monitoring background task progress * Handle queued tasks (not yet started) * Improve UX * Update frontend lib version * Bump API version * Fix notification * Simplify UI interface * Implement internal hook * Fix API path sequence * Add unit tests for task detail endpoint * Refactor code into reusable model * Explicit operation_id for API endpoints * Further refactoring * Use 200 response code - axios does not like 202, simplify it * Return task response for validation of part BOM * Fix schema * Cleanup * Run background worker during playwright tests - For full e2e integration testing * Improve hooks and unit testing * Rename custom hooks to meet react naming requirements
This commit is contained in:
@@ -2,6 +2,12 @@
|
||||
|
||||
This file contains historical changelog information for the InvenTree UI components library.
|
||||
|
||||
### 0.9.0 - March 2026
|
||||
|
||||
Exposes the `useMonitorBackgroundTask` hook, which allows plugins to monitor the status of a background task and display notifications when the task is complete. This is useful for plugins that offload long-running tasks to the background and want to provide feedback to the user when the task is complete.
|
||||
|
||||
Renames the `monitorDataOutput` hook to `useMonitorDataOutput` to better reflect the fact that this is a React hook, and to provide a more consistent naming convention for hooks in the library.
|
||||
|
||||
### 0.8.2 - March 2026
|
||||
|
||||
Bug fixes for the `monitorDataOutput` hook - https://github.com/inventree/InvenTree/pull/11458
|
||||
|
||||
119
src/frontend/lib/hooks/MonitorBackgroundTask.tsx
Normal file
119
src/frontend/lib/hooks/MonitorBackgroundTask.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { apiUrl } from '@lib/functions/Api';
|
||||
import { useDocumentVisibility } from '@mantine/hooks';
|
||||
import { notifications, showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconExclamationCircle
|
||||
} from '@tabler/icons-react';
|
||||
import { type QueryClient, useQuery } from '@tanstack/react-query';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { queryClient } from '../../src/App';
|
||||
|
||||
export type MonitorBackgroundTaskProps = {
|
||||
api: AxiosInstance;
|
||||
queryClient?: QueryClient;
|
||||
title?: string;
|
||||
message: string;
|
||||
errorMessage?: string;
|
||||
successMessage?: string;
|
||||
failureMessage?: string;
|
||||
taskId?: string;
|
||||
onSuccess?: () => void;
|
||||
onFailure?: () => void;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for monitoring a background task running on the server
|
||||
*/
|
||||
export default function useMonitorBackgroundTask(
|
||||
props: MonitorBackgroundTaskProps
|
||||
) {
|
||||
const visibility = useDocumentVisibility();
|
||||
|
||||
const [tracking, setTracking] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!props.taskId) {
|
||||
setTracking(true);
|
||||
showNotification({
|
||||
id: `background-task-${props.taskId}`,
|
||||
title: props.title,
|
||||
message: props.message,
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
withCloseButton: false
|
||||
});
|
||||
} else {
|
||||
setTracking(false);
|
||||
}
|
||||
}, [props.taskId]);
|
||||
|
||||
useQuery(
|
||||
{
|
||||
enabled: !!props.taskId && tracking && visibility === 'visible',
|
||||
refetchInterval: 500,
|
||||
queryKey: ['background-task', props.taskId],
|
||||
queryFn: () =>
|
||||
props.api
|
||||
.get(apiUrl(ApiEndpoints.task_overview, props.taskId))
|
||||
.then((response) => {
|
||||
const data = response?.data ?? {};
|
||||
|
||||
if (data.complete) {
|
||||
setTracking(false);
|
||||
props.onComplete?.();
|
||||
|
||||
notifications.update({
|
||||
id: `background-task-${props.taskId}`,
|
||||
title: props.title,
|
||||
loading: false,
|
||||
color: data.success ? 'green' : 'red',
|
||||
message: response.data?.success
|
||||
? (props.successMessage ?? props.message)
|
||||
: (props.failureMessage ?? props.message),
|
||||
icon: response.data?.success ? (
|
||||
<IconCircleCheck />
|
||||
) : (
|
||||
<IconCircleX />
|
||||
),
|
||||
autoClose: 1000,
|
||||
withCloseButton: true
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
props.onSuccess?.();
|
||||
} else {
|
||||
props.onFailure?.();
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Error fetching background task status for task ${props.taskId}:`,
|
||||
error
|
||||
);
|
||||
setTracking(false);
|
||||
props.onError?.(error);
|
||||
|
||||
notifications.update({
|
||||
id: `background-task-${props.taskId}`,
|
||||
title: props.title,
|
||||
loading: false,
|
||||
color: 'red',
|
||||
message: props.errorMessage ?? props.message,
|
||||
icon: <IconExclamationCircle color='red' />,
|
||||
autoClose: 5000,
|
||||
withCloseButton: true
|
||||
});
|
||||
})
|
||||
},
|
||||
queryClient
|
||||
);
|
||||
}
|
||||
@@ -9,48 +9,44 @@ import { ProgressBar } from '../components/ProgressBar';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../functions/Api';
|
||||
|
||||
/**
|
||||
* Hook for monitoring a data output process running on the server
|
||||
*/
|
||||
export default function monitorDataOutput({
|
||||
api,
|
||||
queryClient,
|
||||
title,
|
||||
hostname,
|
||||
id
|
||||
}: {
|
||||
export type MonitorDataOutputProps = {
|
||||
api: AxiosInstance;
|
||||
queryClient?: QueryClient;
|
||||
title: string;
|
||||
hostname?: string;
|
||||
id?: number;
|
||||
}) {
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for monitoring a data output process running on the server
|
||||
*/
|
||||
export default function useMonitorDataOutput(props: MonitorDataOutputProps) {
|
||||
const visibility = useDocumentVisibility();
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!id) {
|
||||
if (!!props.id) {
|
||||
setLoading(true);
|
||||
showNotification({
|
||||
id: `data-output-${id}`,
|
||||
title: title,
|
||||
id: `data-output-${props.id}`,
|
||||
title: props.title,
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
message: <ProgressBar size='lg' value={0} progressLabel />
|
||||
});
|
||||
} else setLoading(false);
|
||||
}, [id, title]);
|
||||
}, [props.id, props.title]);
|
||||
|
||||
useQuery(
|
||||
{
|
||||
enabled: !!id && loading && visibility === 'visible',
|
||||
enabled: !!props.id && loading && visibility === 'visible',
|
||||
refetchInterval: 500,
|
||||
queryKey: ['data-output', id, title],
|
||||
queryKey: ['data-output', props.id, props.title],
|
||||
queryFn: () =>
|
||||
api
|
||||
.get(apiUrl(ApiEndpoints.data_output, id))
|
||||
props.api
|
||||
.get(apiUrl(ApiEndpoints.data_output, props.id))
|
||||
.then((response) => {
|
||||
const data = response?.data ?? {};
|
||||
|
||||
@@ -61,21 +57,21 @@ export default function monitorDataOutput({
|
||||
data?.error ?? data?.errors?.error ?? t`Process failed`;
|
||||
|
||||
notifications.update({
|
||||
id: `data-output-${id}`,
|
||||
id: `data-output-${props.id}`,
|
||||
loading: false,
|
||||
icon: <IconExclamationCircle />,
|
||||
autoClose: 2500,
|
||||
title: title,
|
||||
title: props.title,
|
||||
message: error,
|
||||
color: 'red'
|
||||
});
|
||||
} else if (data.complete) {
|
||||
setLoading(false);
|
||||
notifications.update({
|
||||
id: `data-output-${id}`,
|
||||
id: `data-output-${props.id}`,
|
||||
loading: false,
|
||||
autoClose: 2500,
|
||||
title: title,
|
||||
title: props.title,
|
||||
message: t`Process completed successfully`,
|
||||
color: 'green',
|
||||
icon: <IconCircleCheck />
|
||||
@@ -83,7 +79,7 @@ export default function monitorDataOutput({
|
||||
|
||||
if (data.output) {
|
||||
const url = data.output;
|
||||
const base = hostname ?? window.location.origin;
|
||||
const base = props.hostname ?? window.location.origin;
|
||||
|
||||
const downloadUrl = new URL(url, base);
|
||||
|
||||
@@ -91,7 +87,7 @@ export default function monitorDataOutput({
|
||||
}
|
||||
} else {
|
||||
notifications.update({
|
||||
id: `data-output-${id}`,
|
||||
id: `data-output-${props.id}`,
|
||||
loading: true,
|
||||
autoClose: false,
|
||||
withCloseButton: false,
|
||||
@@ -110,19 +106,19 @@ export default function monitorDataOutput({
|
||||
return data;
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
console.error('Error in monitorDataOutput:', error);
|
||||
console.error('Error in useMonitorDataOutput:', error);
|
||||
setLoading(false);
|
||||
notifications.update({
|
||||
id: `data-output-${id}`,
|
||||
id: `data-output-${props.id}`,
|
||||
loading: false,
|
||||
autoClose: 2500,
|
||||
title: title,
|
||||
title: props.title,
|
||||
message: error.message || t`Process failed`,
|
||||
color: 'red'
|
||||
});
|
||||
return {};
|
||||
})
|
||||
},
|
||||
queryClient
|
||||
props.queryClient
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,4 +73,11 @@ export {
|
||||
} from './components/RowActions';
|
||||
|
||||
// Shared hooks
|
||||
export { default as monitorDataOutput } from './hooks/MonitorDataOutput';
|
||||
export {
|
||||
default as useMonitorDataOutput,
|
||||
type MonitorDataOutputProps
|
||||
} from './hooks/MonitorDataOutput';
|
||||
export {
|
||||
default as useMonitorBackgroundTask,
|
||||
type MonitorBackgroundTaskProps
|
||||
} from './hooks/MonitorBackgroundTask';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@inventreedb/ui",
|
||||
"description": "UI components for the InvenTree project",
|
||||
"version": "0.8.2",
|
||||
"version": "0.9.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -96,6 +96,15 @@ export default defineConfig({
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
timeout: 120 * 1000
|
||||
},
|
||||
{
|
||||
command: 'invoke worker',
|
||||
env: {
|
||||
INVENTREE_DEBUG: 'True',
|
||||
INVENTREE_LOG_LEVEL: 'INFO',
|
||||
INVENTREE_PLUGINS_ENABLED: 'True',
|
||||
INVENTREE_PLUGINS_MANDATORY: 'samplelocate'
|
||||
}
|
||||
}
|
||||
],
|
||||
globalSetup: './playwright/global-setup.ts',
|
||||
|
||||
18
src/frontend/src/hooks/UseBackgroundTask.tsx
Normal file
18
src/frontend/src/hooks/UseBackgroundTask.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import useMonitorBackgroundTask, {
|
||||
type MonitorBackgroundTaskProps
|
||||
} from '@lib/hooks/MonitorBackgroundTask';
|
||||
import { useApi } from '../contexts/ApiContext';
|
||||
|
||||
/**
|
||||
* Hook for monitoring the progress of a background task running on the server
|
||||
*/
|
||||
export default function useBackgroundTask(
|
||||
props: Omit<MonitorBackgroundTaskProps, 'api'>
|
||||
) {
|
||||
const api = useApi();
|
||||
|
||||
return useMonitorBackgroundTask({
|
||||
...props,
|
||||
api: api
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import monitorDataOutput from '@lib/hooks/MonitorDataOutput';
|
||||
import useMonitorDataOutput from '@lib/hooks/MonitorDataOutput';
|
||||
import { useApi } from '../contexts/ApiContext';
|
||||
import { useLocalState } from '../states/LocalState';
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function useDataOutput({
|
||||
const api = useApi();
|
||||
const { getHost } = useLocalState.getState();
|
||||
|
||||
return monitorDataOutput({
|
||||
return useMonitorDataOutput({
|
||||
api: api,
|
||||
title: title,
|
||||
id: id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Accordion, Alert, Divider, Stack, Text } from '@mantine/core';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
|
||||
import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { StylishText } from '../../../../components/items/StylishText';
|
||||
import { errorCodeLink } from '../../../../components/nav/Alerts';
|
||||
import { FactCollection } from '../../../../components/settings/FactCollection';
|
||||
@@ -26,8 +27,18 @@ export default function TaskManagementPanel() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{taskInfo?.is_running == false && (
|
||||
<Alert title={t`Background worker not running`} color='red'>
|
||||
{taskInfo?.is_running ? (
|
||||
<Alert
|
||||
title={t`Background worker running`}
|
||||
color='green'
|
||||
icon={<IconCircleCheck />}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
title={t`Background worker not running`}
|
||||
color='red'
|
||||
icon={<IconExclamationCircle />}
|
||||
>
|
||||
<Text>{t`The background task manager service is not running. Contact your system administrator.`}</Text>
|
||||
{errorCodeLink('INVE-W5')}
|
||||
</Alert>
|
||||
|
||||
@@ -81,6 +81,7 @@ import { useApi } from '../../contexts/ApiContext';
|
||||
import { formatDecimal, formatPriceRange } from '../../defaults/formatters';
|
||||
import { usePartFields } from '../../forms/PartForms';
|
||||
import { useFindSerialNumberForm } from '../../forms/StockForms';
|
||||
import useBackgroundTask from '../../hooks/UseBackgroundTask';
|
||||
import {
|
||||
useApiFormModal,
|
||||
useCreateApiFormModal,
|
||||
@@ -168,6 +169,17 @@ function BomValidationInformation({
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
const [taskId, setTaskId] = useState<string>('');
|
||||
|
||||
useBackgroundTask({
|
||||
taskId: taskId,
|
||||
message: t`Validating BOM`,
|
||||
successMessage: t`BOM validated`,
|
||||
onComplete: () => {
|
||||
bomInformationQuery.refetch();
|
||||
}
|
||||
});
|
||||
|
||||
const validateBom = useApiFormModal({
|
||||
url: ApiEndpoints.bom_validate,
|
||||
method: 'PUT',
|
||||
@@ -184,9 +196,14 @@ function BomValidationInformation({
|
||||
<Text>{t`Do you want to validate the bill of materials for this assembly?`}</Text>
|
||||
</Alert>
|
||||
),
|
||||
successMessage: t`Bill of materials scheduled for validation`,
|
||||
onFormSuccess: () => {
|
||||
bomInformationQuery.refetch();
|
||||
successMessage: null,
|
||||
onFormSuccess: (response: any) => {
|
||||
// If the process has been offloaded to a background task
|
||||
if (response.task_id) {
|
||||
setTaskId(response.task_id);
|
||||
} else {
|
||||
bomInformationQuery.refetch();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -140,6 +140,44 @@ test('Parts - BOM', async ({ browser }) => {
|
||||
await page.getByRole('button', { name: 'Close' }).click();
|
||||
});
|
||||
|
||||
/**
|
||||
* Perform BOM validation process
|
||||
* Note that this is a "background task" which is monitored by the "useBackgroundTask" hook
|
||||
*/
|
||||
test('Parts - BOM Validation', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'part/107/bom' });
|
||||
|
||||
// Run BOM validation step
|
||||
await page
|
||||
.getByRole('button', { name: 'action-button-validate-bom' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Background task monitoring
|
||||
await page.getByText('Validating BOM').waitFor();
|
||||
await page.getByText('BOM validated').waitFor();
|
||||
|
||||
await page.getByRole('button', { name: 'bom-validation-info' }).hover();
|
||||
await page.getByText('Validated By: allaccessAlly').waitFor();
|
||||
|
||||
// Edit line item, to ensure BOM is not valid next time around
|
||||
const cell = await page.getByRole('cell', { name: 'Red paint Red Paint' });
|
||||
await clickOnRowMenu(cell);
|
||||
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
|
||||
|
||||
const input = await page.getByRole('textbox', {
|
||||
name: 'number-field-quantity'
|
||||
});
|
||||
|
||||
const value = await input.inputValue();
|
||||
|
||||
const nextValue = Number.parseFloat(value) + 0.24;
|
||||
|
||||
await input.fill(`${nextValue.toFixed(3)}`);
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
await page.getByText('BOM item updated').waitFor();
|
||||
});
|
||||
|
||||
test('Parts - Editing', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, { url: 'part/104/details' });
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { createApi } from './api.js';
|
||||
import { expect, test } from './baseFixtures.js';
|
||||
import { adminuser, allaccessuser, stevenuser } from './defaults.js';
|
||||
@@ -314,6 +315,29 @@ test('Settings - Admin', async ({ browser }) => {
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
});
|
||||
|
||||
test('Settings - Admin - Background Tasks', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
user: adminuser,
|
||||
url: 'settings/admin/background'
|
||||
});
|
||||
|
||||
// Background worker should be running, and idle
|
||||
await page.getByText('Background worker running').waitFor();
|
||||
await page.getByText('Failed Tasks0').waitFor();
|
||||
await page.getByText('Pending Tasks0').waitFor();
|
||||
|
||||
// Expand the "scheduled tasks" view
|
||||
await page.getByRole('button', { name: 'Scheduled Tasks' }).click();
|
||||
|
||||
// Check for some expected values
|
||||
await page
|
||||
.getByRole('cell', { name: 'InvenTree.tasks.delete_successful_tasks' })
|
||||
.waitFor();
|
||||
await page
|
||||
.getByRole('cell', { name: 'InvenTree.tasks.check_for_migrations' })
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
test('Settings - Admin - Barcode History', async ({ browser }) => {
|
||||
// Login with admin credentials
|
||||
const page = await doCachedLogin(browser, {
|
||||
@@ -529,7 +553,7 @@ test('Settings - Auth - Email', async ({ browser }) => {
|
||||
await page.getByText('Currently no email addresses are registered').waitFor();
|
||||
});
|
||||
|
||||
async function testColorPicker(page, ref: string) {
|
||||
async function testColorPicker(page: Page, ref: string) {
|
||||
const element = page.getByLabel(ref);
|
||||
await element.click();
|
||||
const box = (await element.boundingBox())!;
|
||||
@@ -539,8 +563,7 @@ async function testColorPicker(page, ref: string) {
|
||||
|
||||
test('Settings - Auth - Tokens', async ({ browser }) => {
|
||||
const page = await doCachedLogin(browser, {
|
||||
username: 'allaccess',
|
||||
password: 'nolimits',
|
||||
user: allaccessuser,
|
||||
url: 'settings/user/'
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user