2
0
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:
Oliver
2026-03-15 14:11:22 +11:00
committed by GitHub
parent 133d254ba7
commit 6830ba5efe
20 changed files with 482 additions and 54 deletions

View File

@@ -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

View 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
);
}

View File

@@ -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
);
}

View File

@@ -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';

View File

@@ -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",

View File

@@ -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',

View 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
});
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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();
}
}
});

View File

@@ -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' });

View File

@@ -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/'
});