mirror of
https://github.com/inventree/InvenTree.git
synced 2026-03-21 11:44:42 +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:
@@ -1,11 +1,14 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 463
|
||||
INVENTREE_API_VERSION = 464
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v464 -> 2026-03-15 : https://github.com/inventree/InvenTree/pull/11527
|
||||
- Add API endpoint for monitoring the progress of a particular background task
|
||||
|
||||
v463 -> 2026-03-12 : https://github.com/inventree/InvenTree/pull/11499
|
||||
- Allow "bulk update" actions against StockItem endpoint
|
||||
|
||||
|
||||
@@ -953,6 +953,7 @@ Q_CLUSTER = {
|
||||
'max_attempts': int(
|
||||
get_setting('INVENTREE_BACKGROUND_MAX_ATTEMPTS', 'background.max_attempts', 5)
|
||||
),
|
||||
'save_limit': 1000,
|
||||
'queue_limit': 50,
|
||||
'catch_up': False,
|
||||
'bulk': 10,
|
||||
|
||||
@@ -161,13 +161,20 @@ def record_task_success(task_name: str):
|
||||
|
||||
def offload_task(
|
||||
taskname, *args, force_async=False, force_sync=False, **kwargs
|
||||
) -> bool:
|
||||
) -> str | bool:
|
||||
"""Create an AsyncTask if workers are running. This is different to a 'scheduled' task, in that it only runs once!
|
||||
|
||||
If workers are not running or force_sync flag, is set then the task is ran synchronously.
|
||||
|
||||
Arguments:
|
||||
taskname: The name of the task to be run, in the format 'app.module.function'
|
||||
*args: Positional arguments to be passed to the task function
|
||||
force_async: If True, force the task to be offloaded (even if workers are not running)
|
||||
force_sync: If True, force the task to be run synchronously (even if workers are running)
|
||||
**kwargs: Keyword arguments to be passed to the task function
|
||||
|
||||
Returns:
|
||||
bool: True if the task was offloaded (or ran), False otherwise
|
||||
str | bool: Task ID if the task was offloaded, True if ran synchronously, False otherwise
|
||||
"""
|
||||
from InvenTree.exceptions import log_error
|
||||
|
||||
@@ -203,6 +210,9 @@ def offload_task(
|
||||
task = AsyncTask(taskname, *args, group=group, **kwargs)
|
||||
with tracer.start_as_current_span(f'async worker: {taskname}'):
|
||||
task.run()
|
||||
|
||||
# Return the ID of the offloaded task, so that it can be tracked if needed
|
||||
return task.id
|
||||
except ImportError:
|
||||
raise_warning(f"WARNING: '{taskname}' not offloaded - Function not found")
|
||||
return False
|
||||
@@ -265,6 +275,40 @@ def offload_task(
|
||||
return True
|
||||
|
||||
|
||||
def get_queued_task(task_id: str):
|
||||
"""Find the task in the queue, if it exists.
|
||||
|
||||
Note that the OrmQ table does NOT keep the task ID as a database field,
|
||||
it is instead stored in the payload data.
|
||||
If there are a large number of pending tasks, this query may be inefficient,
|
||||
but there is no other way to find a queued task by ID.
|
||||
"""
|
||||
offset = 0
|
||||
limit = 500
|
||||
|
||||
if not task_id:
|
||||
# Return early if no task ID was provided
|
||||
return None
|
||||
|
||||
task_id = str(task_id)
|
||||
|
||||
from django_q.models import OrmQ
|
||||
|
||||
while True:
|
||||
queued_tasks = OrmQ.objects.all().order_by('id')[offset : offset + limit]
|
||||
if not queued_tasks:
|
||||
break
|
||||
|
||||
for task in queued_tasks:
|
||||
if task.task_id() == task_id:
|
||||
return task
|
||||
|
||||
offset += limit
|
||||
|
||||
# No matching task was discovered
|
||||
return None
|
||||
|
||||
|
||||
@dataclass()
|
||||
class ScheduledTask:
|
||||
"""A scheduled task.
|
||||
|
||||
@@ -17,8 +17,8 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
import django_filters.rest_framework.filters as rest_filters
|
||||
import django_q.models
|
||||
import django_q.tasks
|
||||
from django_filters.rest_framework.filterset import FilterSet
|
||||
from django_q.tasks import async_task
|
||||
from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from error_report.models import Error
|
||||
@@ -115,7 +115,7 @@ class WebhookView(CsrfExemptMixin, APIView):
|
||||
# process data
|
||||
message = self.webhook.save_data(payload, headers, request)
|
||||
if self.run_async:
|
||||
async_task(self._process_payload, message.id)
|
||||
django_q.tasks.async_task(self._process_payload, message.id)
|
||||
else:
|
||||
self._process_result(
|
||||
self.webhook.process_payload(message, payload, headers), message
|
||||
@@ -564,13 +564,29 @@ class ErrorMessageDetail(RetrieveUpdateDestroyAPI):
|
||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
||||
|
||||
|
||||
class BackgroundTaskDetail(APIView):
|
||||
"""Detail view for a single background task."""
|
||||
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
@extend_schema(responses={200: common.serializers.TaskDetailSerializer})
|
||||
def get(self, request, task_id, *args, **kwargs):
|
||||
"""Fetch information regarding a particular background task ID."""
|
||||
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
|
||||
|
||||
return Response(response, status=response['http_status'])
|
||||
|
||||
|
||||
class BackgroundTaskOverview(APIView):
|
||||
"""Provides an overview of the background task queue status."""
|
||||
|
||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
||||
serializer_class = None
|
||||
|
||||
@extend_schema(responses={200: common.serializers.TaskOverviewSerializer})
|
||||
@extend_schema(
|
||||
operation_id='background_task_overview',
|
||||
responses={200: common.serializers.TaskOverviewSerializer},
|
||||
)
|
||||
def get(self, request, fmt=None):
|
||||
"""Return information about the current status of the background task queue."""
|
||||
import django_q.models as q_models
|
||||
@@ -1396,6 +1412,9 @@ common_api_urls = [
|
||||
name='api-scheduled-task-list',
|
||||
),
|
||||
path('failed/', FailedTaskList.as_view(), name='api-failed-task-list'),
|
||||
path(
|
||||
'<str:task_id>/', BackgroundTaskDetail.as_view(), name='api-task-detail'
|
||||
),
|
||||
path('', BackgroundTaskOverview.as_view(), name='api-task-overview'),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -522,6 +522,78 @@ class ErrorMessageSerializer(InvenTreeModelSerializer):
|
||||
read_only_fields = ['when', 'info', 'data', 'path', 'pk']
|
||||
|
||||
|
||||
class TaskDetailSerializer(serializers.Serializer):
|
||||
"""Serializer for a background task detail."""
|
||||
|
||||
task_id = serializers.CharField(read_only=True)
|
||||
exists = serializers.BooleanField(read_only=True)
|
||||
pending = serializers.BooleanField(read_only=True)
|
||||
complete = serializers.BooleanField(read_only=True)
|
||||
success = serializers.BooleanField(read_only=True)
|
||||
http_status = serializers.IntegerField(read_only=True)
|
||||
|
||||
@classmethod
|
||||
def from_task(cls, task_id: str | bool | None) -> 'TaskDetailSerializer':
|
||||
"""Create a TaskDetailSerializer instance from a django_q Task.
|
||||
|
||||
Arguments:
|
||||
task_id: The ID of the task to retrieve details for.
|
||||
|
||||
Returns:
|
||||
An instance of TaskDetailSerializer with the task details.
|
||||
|
||||
Notes:
|
||||
- If the provided task_id is None, the task has not been run, or has errored out
|
||||
- If the provided task_id is a boolean, the task has been run synchronously, and the boolean value indicates success or failure
|
||||
- If the provided task_id is a string, the task has been offloaded to the background worker, and the details can be from the database
|
||||
|
||||
"""
|
||||
from InvenTree.tasks import get_queued_task
|
||||
|
||||
if task_id is None or type(task_id) is bool:
|
||||
# If the task_id is a boolean, the task has been run synchronously
|
||||
return cls({
|
||||
'task_id': '',
|
||||
'exists': False,
|
||||
'pending': False,
|
||||
'complete': task_id is not None,
|
||||
'success': False if task_id is None else bool(task_id),
|
||||
'http_status': 404 if task_id is None else 200,
|
||||
})
|
||||
|
||||
# A non-boolean result indicates that the task has been offloaded to the background worker
|
||||
success = django_q.models.Success.objects.filter(id=task_id).first()
|
||||
failure = django_q.models.Failure.objects.filter(id=task_id).first()
|
||||
task = (
|
||||
success
|
||||
or failure
|
||||
or django_q.models.Task.objects.filter(id=task_id).first()
|
||||
)
|
||||
queued = False
|
||||
|
||||
exists = bool(success or failure or task)
|
||||
|
||||
if not exists:
|
||||
# If the task has not been started yet, it may be present in the queue
|
||||
queued = bool(get_queued_task(task_id))
|
||||
|
||||
complete = bool(success) or bool(failure)
|
||||
|
||||
# Determine the http_status code for the task
|
||||
# - 200: Task exists and has been completed
|
||||
# - 404: Task does not exist
|
||||
http_status = 200 if exists or queued else 404
|
||||
|
||||
return cls({
|
||||
'task_id': task_id,
|
||||
'exists': exists or queued,
|
||||
'pending': queued,
|
||||
'complete': complete,
|
||||
'success': bool(success),
|
||||
'http_status': http_status,
|
||||
})
|
||||
|
||||
|
||||
class TaskOverviewSerializer(serializers.Serializer):
|
||||
"""Serializer for background task overview."""
|
||||
|
||||
|
||||
@@ -1107,6 +1107,41 @@ class TaskListApiTests(InvenTreeAPITestCase):
|
||||
for task in response.data:
|
||||
self.assertEqual(task['name'], 'time.sleep')
|
||||
|
||||
def test_task_detail(self):
|
||||
"""Test the BackgroundTaskDetail API endpoint."""
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
# Force run a task
|
||||
result = offload_task('fake_module.test_task', force_sync=True)
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(type(result), bool)
|
||||
|
||||
# Schedule a dummy task - and ensure it offloads to the worker
|
||||
task_id = offload_task('fake_module.test_task', force_async=True)
|
||||
self.assertIsNotNone(task_id)
|
||||
self.assertEqual(type(task_id), str)
|
||||
|
||||
url = reverse('api-task-detail', kwargs={'task_id': task_id})
|
||||
|
||||
data = self.get(url, expected_code=200).data
|
||||
|
||||
self.assertEqual(data['task_id'], task_id)
|
||||
self.assertTrue(data['exists'])
|
||||
self.assertTrue(data['pending'])
|
||||
self.assertFalse(data['complete'])
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
# Perform a lookup for a non-existent task
|
||||
url = reverse('api-task-detail', kwargs={'task_id': 'doesnotexist'})
|
||||
|
||||
data = self.get(url, expected_code=404).data
|
||||
|
||||
self.assertEqual(data['task_id'], 'doesnotexist')
|
||||
self.assertFalse(data['exists'])
|
||||
self.assertFalse(data['pending'])
|
||||
self.assertFalse(data['complete'])
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
|
||||
class WebhookMessageTests(TestCase):
|
||||
"""Tests for webhooks."""
|
||||
|
||||
@@ -8,10 +8,11 @@ import django_filters.rest_framework.filters as rest_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_filters.rest_framework.filterset import FilterSet
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.response import Response
|
||||
|
||||
import common.serializers
|
||||
import part.tasks as part_tasks
|
||||
from data_exporter.mixins import DataExportViewMixin
|
||||
from InvenTree.api import (
|
||||
@@ -614,8 +615,18 @@ class PartValidateBOM(RetrieveUpdateAPI):
|
||||
queryset = Part.objects.all()
|
||||
serializer_class = part_serializers.PartBomValidateSerializer
|
||||
|
||||
@extend_schema(
|
||||
responses={
|
||||
200: common.serializers.TaskDetailSerializer,
|
||||
404: common.serializers.TaskDetailSerializer,
|
||||
}
|
||||
)
|
||||
def update(self, request, *args, **kwargs):
|
||||
"""Validate the referenced BomItem instance."""
|
||||
"""Validate the referenced BomItem instance.
|
||||
|
||||
As this task if offloaded to the background worker,
|
||||
we return information about the background task which is performing the validation.
|
||||
"""
|
||||
part = self.get_object()
|
||||
|
||||
partial = kwargs.pop('partial', False)
|
||||
@@ -629,7 +640,7 @@ class PartValidateBOM(RetrieveUpdateAPI):
|
||||
valid = str2bool(serializer.validated_data.get('valid', False))
|
||||
|
||||
# BOM validation may take some time, so we offload it to a background task
|
||||
offload_task(
|
||||
task_id = offload_task(
|
||||
part_tasks.validate_bom,
|
||||
part.pk,
|
||||
valid,
|
||||
@@ -637,10 +648,8 @@ class PartValidateBOM(RetrieveUpdateAPI):
|
||||
group='part',
|
||||
)
|
||||
|
||||
# Re-serialize the response
|
||||
serializer = self.get_serializer(part, many=False)
|
||||
|
||||
return Response(serializer.data)
|
||||
response = common.serializers.TaskDetailSerializer.from_task(task_id).data
|
||||
return Response(response, status=response['http_status'])
|
||||
|
||||
|
||||
class PartFilter(FilterSet):
|
||||
|
||||
@@ -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