Fix machine request pickeling and settings (#6772)

* Fix machine request pickeling

* fix precommit

* fix: shared state between workers and main thread for machine registry

* remove last usage of legacy PUI form framework to fix machine edit/delete modal

* reset cache before initialization

* update documentation

* fix: invalidating cache

* implement machine registry hash to check if a reload is required

* trigger: ci

* fix: request bug

* fix: test

* trigger: ci

* add clear errors and improve restart hook

* auto initialize not initialized machines when changing active state

* fix: tests
This commit is contained in:
Lukas 2024-07-12 16:22:40 +02:00 committed by GitHub
parent 767b76314e
commit 23a394d740
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 292 additions and 247 deletions

View File

@ -6,6 +6,9 @@ title: Machines
InvenTree has a builtin machine registry. There are different machine types available where each type can have different drivers. Drivers and even custom machine types can be provided by plugins.
!!! info "Requires Redis"
If the machines features is used in production setup using workers, a shared [redis cache](../../start/docker.md#redis-cache) is required to function properly.
### Registry
The machine registry is the main component which gets initialized on server start and manages all configured machines.
@ -21,6 +24,13 @@ The machine registry initialization process can be divided into three stages:
2. The driver.init_driver function is called for each used driver
3. The machine.initialize function is called for each machine, which calls the driver.init_machine function for each machine, then the machine.initialized state is set to true
#### Production setup (with a worker)
If a worker is connected, there exist multiple instances of the machine registry (one in each worker thread and one in the main thread) due to the nature of how python handles state in different processes. Therefore the machine instances and drivers are instantiated multiple times (The `__init__` method is called multiple times). But the init functions and update hooks (e.g. `init_machine`) are only called once from the main process.
The registry, driver and machine state (e.g. machine status codes, errors, ...) is stored in the cache. Therefore a shared redis cache is needed. (The local in-memory cache which is used by default is not capable to cache across multiple processes)
### Machine types
Each machine type can provide a different type of connection functionality between inventree and a physical machine. These machine types are already built into InvenTree.
@ -86,6 +96,7 @@ The machine type class gets instantiated for each machine on server startup and
- update
- restart
- handle_error
- clear_errors
- get_setting
- set_setting
- check_setting

View File

@ -2,8 +2,10 @@
import inspect
from pathlib import Path
from typing import Any, Callable
from django.conf import settings
from django.core.cache import cache
from plugin import registry as plg_registry
@ -104,3 +106,37 @@ class ClassProviderMixin:
except ValueError:
# Path(...).relative_to throws an ValueError if its not relative to the InvenTree source base dir
return False
def get_shared_class_instance_state_mixin(get_state_key: Callable[[type], str]):
"""Get a mixin class that provides shared state for classes across the main application and worker.
Arguments:
get_state_key: A function that returns the key for the shared state when given a class instance.
"""
class SharedClassStateMixinClass:
"""Mixin to provide shared state for classes across the main application and worker."""
def set_shared_state(self, key: str, value: Any):
"""Set a shared state value for this machine.
Arguments:
key: The key for the shared state
value: The value to set
"""
cache.set(self._get_key(key), value, timeout=None)
def get_shared_state(self, key: str, default=None):
"""Get a shared state value for this machine.
Arguments:
key: The key for the shared state
"""
return cache.get(self._get_key(key)) or default
def _get_key(self, key: str):
"""Get the key for this class instance."""
return f'{get_state_key(self)}:{key}'
return SharedClassStateMixinClass

View File

@ -26,7 +26,6 @@ class MachineConfig(AppConfig):
if (
not canAppAccessDatabase(allow_test=True)
or not isPluginRegistryLoaded()
or not isInMainThread()
or isRunningMigrations()
or isImportingData()
):
@ -37,7 +36,7 @@ class MachineConfig(AppConfig):
try:
logger.info('Loading InvenTree machines')
registry.initialize()
registry.initialize(main=isInMainThread())
except (OperationalError, ProgrammingError):
# Database might not yet be ready
logger.warn('Database was not ready for initializing machines')

View File

@ -3,7 +3,11 @@
from typing import TYPE_CHECKING, Any, Literal, Union
from generic.states import StatusCode
from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin
from InvenTree.helpers_mixin import (
ClassProviderMixin,
ClassValidationMixin,
get_shared_class_instance_state_mixin,
)
# Import only for typechecking, otherwise this throws cyclic import errors
if TYPE_CHECKING:
@ -44,7 +48,11 @@ class MachineStatus(StatusCode):
"""
class BaseDriver(ClassValidationMixin, ClassProviderMixin):
class BaseDriver(
ClassValidationMixin,
ClassProviderMixin,
get_shared_class_instance_state_mixin(lambda x: f'machine:driver:{x.SLUG}'),
):
"""Base class for all machine drivers.
Attributes:
@ -69,8 +77,6 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin):
"""Base driver __init__ method."""
super().__init__()
self.errors: list[Union[str, Exception]] = []
def init_driver(self):
"""This method gets called after all machines are created and can be used to initialize the driver.
@ -133,10 +139,20 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin):
Arguments:
error: Exception or string
"""
self.errors.append(error)
self.set_shared_state('errors', self.errors + [error])
# --- state getters/setters
@property
def errors(self) -> list[Union[str, Exception]]:
"""List of driver errors."""
return self.get_shared_state('errors', [])
class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
class BaseMachineType(
ClassValidationMixin,
ClassProviderMixin,
get_shared_class_instance_state_mixin(lambda x: f'machine:machine:{x.pk}'),
):
"""Base class for machine types.
Attributes:
@ -178,12 +194,6 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
from machine import registry
from machine.models import MachineSetting
self.errors: list[Union[str, Exception]] = []
self.initialized = False
self.status = self.default_machine_status
self.status_text: str = ''
self.pk = machine_config.pk
self.driver = registry.get_driver_instance(machine_config.driver)
@ -208,8 +218,6 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
(self.driver_settings, MachineSetting.ConfigType.DRIVER),
]
self.restart_required = False
def __str__(self):
"""String representation of a machine."""
return f'{self.name}'
@ -272,16 +280,32 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
try:
self.driver.update_machine(old_state, self)
# check if the active state has changed and initialize the machine if necessary
if old_state['active'] != self.active:
if self.initialized is False and self.active is True:
self.initialize()
elif self.initialized is True and self.active is False:
self.initialized = False
except Exception as e:
self.handle_error(e)
def restart(self):
"""Machine restart function, can be used to manually restart the machine from the admin ui."""
"""Machine restart function, can be used to manually restart the machine from the admin ui.
This will first reset the machines state (errors, status, status_text) and then call the drivers restart function.
"""
if self.driver is None:
return
try:
# reset the machine state
self.restart_required = False
self.reset_errors()
self.set_status(self.default_machine_status)
self.set_status_text('')
# call the driver restart function
self.driver.restart_machine(self)
except Exception as e:
self.handle_error(e)
@ -293,7 +317,11 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
Arguments:
error: Exception or string
"""
self.errors.append(error)
self.set_shared_state('errors', self.errors + [error])
def reset_errors(self):
"""Helper function for resetting the error list for a machine."""
self.set_shared_state('errors', [])
def get_setting(
self, key: str, config_type_str: Literal['M', 'D'], cache: bool = False
@ -364,7 +392,7 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
Arguments:
status: The new MachineStatus code to set
"""
self.status = status
self.set_shared_state('status', status.value)
def set_status_text(self, status_text: str):
"""Set the machine status text. It can be any arbitrary text.
@ -372,4 +400,39 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
Arguments:
status_text: The new status text to set
"""
self.status_text = status_text
self.set_shared_state('status_text', status_text)
# --- state getters/setters
@property
def initialized(self) -> bool:
"""Initialized state of the machine."""
return self.get_shared_state('initialized', False)
@initialized.setter
def initialized(self, value: bool):
self.set_shared_state('initialized', value)
@property
def restart_required(self) -> bool:
"""Restart required state of the machine."""
return self.get_shared_state('restart_required', False)
@restart_required.setter
def restart_required(self, value: bool):
self.set_shared_state('restart_required', value)
@property
def errors(self) -> list[Union[str, Exception]]:
"""List of machine errors."""
return self.get_shared_state('errors', [])
@property
def status(self) -> MachineStatus:
"""Machine status code."""
status_code = self.get_shared_state('status', self.default_machine_status.value)
return self.MACHINE_STATUS(status_code)
@property
def status_text(self) -> str:
"""Machine status text."""
return self.get_shared_state('status_text', '')

View File

@ -2,9 +2,10 @@
from typing import Union, cast
from django.contrib.auth.models import AnonymousUser
from django.db import models
from django.db.models.query import QuerySet
from django.http import HttpResponse, JsonResponse
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.utils.translation import gettext_lazy as _
from PIL.Image import Image
@ -34,7 +35,6 @@ class LabelPrinterBaseDriver(BaseDriver):
machine: 'LabelPrinterMachine',
label: LabelTemplate,
item: models.Model,
request: Request,
**kwargs,
) -> None:
"""Print a single label with the provided template and item.
@ -43,7 +43,6 @@ class LabelPrinterBaseDriver(BaseDriver):
machine: The LabelPrintingMachine instance that should be used for printing
label: The LabelTemplate object to use for printing
item: The database item to print (e.g. StockItem instance)
request: The HTTP request object which triggered this print job
Keyword Arguments:
printing_options (dict): The printing options set for this print job defined in the PrintingOptionsSerializer
@ -57,8 +56,7 @@ class LabelPrinterBaseDriver(BaseDriver):
self,
machine: 'LabelPrinterMachine',
label: LabelTemplate,
items: QuerySet,
request: Request,
items: QuerySet[models.Model],
**kwargs,
) -> Union[None, JsonResponse]:
"""Print one or more labels with the provided template and items.
@ -67,7 +65,6 @@ class LabelPrinterBaseDriver(BaseDriver):
machine: The LabelPrintingMachine instance that should be used for printing
label: The LabelTemplate object to use for printing
items: The list of database items to print (e.g. StockItem instances)
request: The HTTP request object which triggered this print job
Keyword Arguments:
printing_options (dict): The printing options set for this print job defined in the PrintingOptionsSerializer
@ -81,7 +78,7 @@ class LabelPrinterBaseDriver(BaseDriver):
but this can be overridden by the particular driver.
"""
for item in items:
self.print_label(machine, label, item, request, **kwargs)
self.print_label(machine, label, item, **kwargs)
def get_printers(
self, label: LabelTemplate, items: QuerySet, **kwargs
@ -123,56 +120,50 @@ class LabelPrinterBaseDriver(BaseDriver):
return cast(LabelPrintingMixin, plg)
def render_to_pdf(
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
self, label: LabelTemplate, item: models.Model, **kwargs
) -> HttpResponse:
"""Helper method to render a label to PDF format for a specific item.
Arguments:
label: The LabelTemplate object to render
item: The item to render the label with
request: The HTTP request object which triggered this print job
"""
response = self.machine_plugin.render_to_pdf(label, item, request, **kwargs)
return response
request = self._get_dummy_request()
return self.machine_plugin.render_to_pdf(label, item, request, **kwargs)
def render_to_pdf_data(
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
self, label: LabelTemplate, item: models.Model, **kwargs
) -> bytes:
"""Helper method to render a label to PDF and return it as bytes for a specific item.
Arguments:
label: The LabelTemplate object to render
item: The item to render the label with
request: The HTTP request object which triggered this print job
"""
return (
self.render_to_pdf(label, item, request, **kwargs)
self.render_to_pdf(label, item, **kwargs)
.get_document() # type: ignore
.write_pdf()
)
def render_to_html(
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
) -> str:
def render_to_html(self, label: LabelTemplate, item: models.Model, **kwargs) -> str:
"""Helper method to render a label to HTML format for a specific item.
Arguments:
label: The LabelTemplate object to render
item: The item to render the label with
request: The HTTP request object which triggered this print job
"""
html = self.machine_plugin.render_to_html(label, item, request, **kwargs)
return html
request = self._get_dummy_request()
return self.machine_plugin.render_to_html(label, item, request, **kwargs)
def render_to_png(
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
) -> Image:
self, label: LabelTemplate, item: models.Model, **kwargs
) -> Union[Image, None]:
"""Helper method to render a label to PNG format for a specific item.
Arguments:
label: The LabelTemplate object to render
item: The item to render the label with
request: The HTTP request object which triggered this print job
Keyword Arguments:
pdf_data (bytes): The pdf document as bytes (optional)
@ -182,8 +173,20 @@ class LabelPrinterBaseDriver(BaseDriver):
pdf2image_kwargs (dict): Additional keyword arguments to pass to the
[`pdf2image.convert_from_bytes`](https://pdf2image.readthedocs.io/en/latest/reference.html#pdf2image.pdf2image.convert_from_bytes) method (optional)
"""
png = self.machine_plugin.render_to_png(label, item, request, **kwargs)
return png
request = self._get_dummy_request()
return self.machine_plugin.render_to_png(label, item, request, **kwargs)
def _get_dummy_request(self):
"""Return a dummy request object to it work with legacy code.
Note: this is a private method and can be removed at anytime
"""
r = HttpRequest()
r.META['SERVER_PORT'] = '80'
r.META['SERVER_NAME'] = 'localhost'
r.user = AnonymousUser()
return r
required_overrides = [[print_label, print_labels]]
@ -229,6 +232,7 @@ class LabelPrinterStatus(MachineStatus):
UNKNOWN = 101, _('Unknown'), 'secondary'
PRINTING = 110, _('Printing'), 'primary'
NO_MEDIA = 301, _('No media'), 'warning'
PAPER_JAM = 302, _('Paper jam'), 'warning'
DISCONNECTED = 400, _('Disconnected'), 'danger'

View File

@ -1,15 +1,20 @@
"""Machine registry."""
import logging
from typing import Union
from typing import Union, cast
from uuid import UUID
from django.core.cache import cache
from InvenTree.helpers_mixin import get_shared_class_instance_state_mixin
from machine.machine_type import BaseDriver, BaseMachineType
logger = logging.getLogger('inventree')
class MachineRegistry:
class MachineRegistry(
get_shared_class_instance_state_mixin(lambda _x: f'machine:registry')
):
"""Machine registry class."""
def __init__(self) -> None:
@ -23,17 +28,27 @@ class MachineRegistry:
self.machines: dict[str, BaseMachineType] = {}
self.base_drivers: list[type[BaseDriver]] = []
self.errors: list[Union[str, Exception]] = []
self._hash = None
@property
def errors(self) -> list[Union[str, Exception]]:
"""List of registry errors."""
return cast(list[Union[str, Exception]], self.get_shared_state('errors', []))
def handle_error(self, error: Union[Exception, str]):
"""Helper function for capturing errors with the machine registry."""
self.errors.append(error)
self.set_shared_state('errors', self.errors + [error])
def initialize(self):
def initialize(self, main: bool = False):
"""Initialize the machine registry."""
# clear cache for machines (only needed for global redis cache)
if main and hasattr(cache, 'delete_pattern'): # pragma: no cover
cache.delete_pattern(f'machine:*')
self.discover_machine_types()
self.discover_drivers()
self.load_machines()
self.load_machines(main=main)
def discover_machine_types(self):
"""Discovers all machine types by inferring all classes that inherit the BaseMachineType class."""
@ -113,26 +128,39 @@ class MachineRegistry:
return self.driver_instances.get(slug, None)
def load_machines(self):
def load_machines(self, main: bool = False):
"""Load all machines defined in the database into the machine registry."""
# Imports need to be in this level to prevent early db model imports
from machine.models import MachineConfig
for machine_config in MachineConfig.objects.all():
self.add_machine(machine_config, initialize=False)
self.add_machine(
machine_config, initialize=False, update_registry_hash=False
)
# initialize drivers
for driver in self.driver_instances.values():
driver.init_driver()
# initialize machines only in main thread
if main:
# initialize drivers
for driver in self.driver_instances.values():
driver.init_driver()
# initialize machines after all machine instances were created
for machine in self.machines.values():
if machine.active:
machine.initialize()
# initialize machines after all machine instances were created
for machine in self.machines.values():
if machine.active:
machine.initialize()
logger.info('Initialized %s machines', len(self.machines.keys()))
self._update_registry_hash()
logger.info('Initialized %s machines', len(self.machines.keys()))
else:
self._hash = None # reset hash to force reload hash
logger.info('Loaded %s machines', len(self.machines.keys()))
def add_machine(self, machine_config, initialize=True):
def reload_machines(self):
"""Reload all machines from the database."""
self.machines = {}
self.load_machines()
def add_machine(self, machine_config, initialize=True, update_registry_hash=True):
"""Add a machine to the machine registry."""
machine_type = self.machine_types.get(machine_config.machine_type, None)
if machine_type is None:
@ -145,11 +173,19 @@ class MachineRegistry:
if initialize and machine.active:
machine.initialize()
def update_machine(self, old_machine_state, machine_config):
if update_registry_hash:
self._update_registry_hash()
def update_machine(
self, old_machine_state, machine_config, update_registry_hash=True
):
"""Notify the machine about an update."""
if machine := machine_config.machine:
machine.update(old_machine_state)
if update_registry_hash:
self._update_registry_hash()
def restart_machine(self, machine):
"""Restart a machine."""
machine.restart()
@ -157,6 +193,7 @@ class MachineRegistry:
def remove_machine(self, machine: BaseMachineType):
"""Remove a machine from the registry."""
self.machines.pop(str(machine.pk), None)
self._update_registry_hash()
def get_machines(self, **kwargs):
"""Get loaded machines from registry (By default only initialized machines).
@ -169,6 +206,8 @@ class MachineRegistry:
active: (bool)
base_driver: base driver (class)
"""
self._check_reload()
allowed_fields = [
'name',
'machine_type',
@ -212,6 +251,7 @@ class MachineRegistry:
def get_machine(self, pk: Union[str, UUID]):
"""Get machine from registry by pk."""
self._check_reload()
return self.machines.get(str(pk), None)
def get_drivers(self, machine_type: str):
@ -222,5 +262,37 @@ class MachineRegistry:
if driver.machine_type == machine_type
]
def _calculate_registry_hash(self):
"""Calculate a hash of the machine registry state."""
from hashlib import md5
data = md5()
for pk, machine in self.machines.items():
data.update(str(pk).encode())
try:
data.update(str(machine.machine_config.active).encode())
except:
# machine does not exist anymore, hash will be different
pass
return str(data.hexdigest())
def _check_reload(self):
"""Check if the registry needs to be reloaded, and reload it."""
if not self._hash:
self._hash = self._calculate_registry_hash()
last_hash = self.get_shared_state('hash', None)
if last_hash and last_hash != self._hash:
logger.info('Machine registry has changed - reloading machines')
self.reload_machines()
def _update_registry_hash(self):
"""Save the current registry hash."""
self._hash = self._calculate_registry_hash()
self.set_shared_state('hash', self._hash)
registry: MachineRegistry = MachineRegistry()

View File

@ -32,7 +32,7 @@ class TestMachineRegistryMixin(TestCase):
registry.driver_instances = {}
registry.machines = {}
registry.base_drivers = []
registry.errors = []
registry.set_shared_state('errors', [])
return super().tearDown()
@ -111,7 +111,7 @@ class TestDriverMachineInterface(TestMachineRegistryMixin, TestCase):
self.machines = [self.machine1, self.machine2, self.machine3]
# init registry
registry.initialize()
registry.initialize(main=True)
# mock machine implementation
self.machine_mocks = {
@ -230,7 +230,7 @@ class TestLabelPrinterMachineType(TestMachineRegistryMixin, InvenTreeAPITestCase
active=True,
)
registry.initialize()
registry.initialize(main=True)
driver_instance = cast(
TestingLabelPrinterDriver,
registry.get_driver_instance('testing-label-printer'),

View File

@ -93,11 +93,9 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin):
# execute the print job
if driver.USE_BACKGROUND_WORKER is False:
return driver.print_labels(machine, label, items, request, **print_kwargs)
return driver.print_labels(machine, label, items, **print_kwargs)
offload_task(
driver.print_labels, machine, label, items, request, **print_kwargs
)
offload_task(driver.print_labels, machine, label, items, **print_kwargs)
return JsonResponse({
'success': True,

View File

@ -1,20 +1,12 @@
import { t } from '@lingui/macro';
import { Divider, Stack } from '@mantine/core';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { AxiosResponse } from 'axios';
import { api } from '../App';
import { ApiForm, ApiFormProps } from '../components/forms/ApiForm';
import {
ApiFormFieldSet,
ApiFormFieldType
} from '../components/forms/fields/ApiFormField';
import { StylishText } from '../components/items/StylishText';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { PathParams, apiUrl } from '../states/ApiState';
import { invalidResponse, permissionDenied } from './notifications';
import { generateUniqueId } from './uid';
/**
* Construct an API url from the provided ApiFormProps object
@ -180,143 +172,3 @@ export function constructField({
return def;
}
export interface OpenApiFormProps extends ApiFormProps {
title: string;
cancelText?: string;
cancelColor?: string;
onClose?: () => void;
}
/*
* Construct and open a modal form
* @param title :
*/
export function openModalApiForm(props: OpenApiFormProps) {
// method property *must* be supplied
if (!props.method) {
notifications.show({
title: t`Invalid Form`,
message: t`method parameter not supplied`,
color: 'red'
});
return;
}
// Generate a random modal ID for controller
let modalId: string =
`modal-${props.title}-${props.url}-${props.method}` + generateUniqueId();
props.actions = [
...(props.actions || []),
{
text: props.cancelText ?? t`Cancel`,
color: props.cancelColor ?? 'blue',
onClick: () => {
modals.close(modalId);
}
}
];
const oldFormSuccess = props.onFormSuccess;
props.onFormSuccess = (data) => {
oldFormSuccess?.(data);
modals.close(modalId);
};
let url = constructFormUrl(props.url, props.pk, props.pathParams);
// Make OPTIONS request first
api
.options(url)
.then((response) => {
// Extract available fields from the OPTIONS response (and handle any errors)
let fields: Record<string, ApiFormFieldType> | null = {};
if (!props.ignorePermissionCheck) {
fields = extractAvailableFields(response, props.method);
if (fields == null) {
return;
}
}
const _props = { ...props };
if (_props.fields) {
for (const [k, v] of Object.entries(_props.fields)) {
_props.fields[k] = constructField({
field: v,
definition: fields?.[k]
});
}
}
modals.open({
title: <StylishText size="xl">{props.title}</StylishText>,
modalId: modalId,
size: 'xl',
onClose: () => {
props.onClose ? props.onClose() : null;
},
children: (
<Stack gap={'xs'}>
<Divider />
<ApiForm id={modalId} props={props} optionsLoading={false} />
</Stack>
)
});
})
.catch((error) => {
if (error.response) {
invalidResponse(error.response.status);
} else {
notifications.show({
title: t`Form Error`,
message: error.message,
color: 'red'
});
}
});
}
/**
* Opens a modal form to create a new model instance
*/
export function openCreateApiForm(props: OpenApiFormProps) {
let createProps: OpenApiFormProps = {
...props,
method: 'POST'
};
openModalApiForm(createProps);
}
/**
* Open a modal form to edit a model instance
*/
export function openEditApiForm(props: OpenApiFormProps) {
let editProps: OpenApiFormProps = {
...props,
fetchInitialData: props.fetchInitialData ?? true,
method: 'PATCH'
};
openModalApiForm(editProps);
}
/**
* Open a modal form to delete a model instancel
*/
export function openDeleteApiForm(props: OpenApiFormProps) {
let deleteProps: OpenApiFormProps = {
...props,
method: 'DELETE',
submitText: t`Delete`,
submitColor: 'red',
fields: {}
};
openModalApiForm(deleteProps);
}

View File

@ -1,6 +1,7 @@
import '@mantine/carousel/styles.css';
import '@mantine/charts/styles.css';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import '@mantine/spotlight/styles.css';
import * as Sentry from '@sentry/react';

View File

@ -41,8 +41,11 @@ import {
} from '../../components/render/StatusRenderer';
import { MachineSettingList } from '../../components/settings/SettingList';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { openDeleteApiForm, openEditApiForm } from '../../functions/forms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { TableColumn } from '../Column';
@ -205,8 +208,39 @@ function MachineDrawer({
[refreshAll]
);
const machineEditModal = useEditApiFormModal({
title: t`Edit machine`,
url: ApiEndpoints.machine_list,
pk: machinePk,
fields: useMemo(
() => ({
name: {},
active: {}
}),
[]
),
onClose: () => refreshAll()
});
const machineDeleteModal = useDeleteApiFormModal({
title: t`Delete machine`,
successMessage: t`Machine successfully deleted.`,
url: ApiEndpoints.machine_list,
pk: machinePk,
preFormContent: (
<Text>{t`Are you sure you want to remove the machine "${machine?.name}"?`}</Text>
),
onFormSuccess: () => {
refreshTable();
navigate(-1);
}
});
return (
<Stack gap="xs">
{machineEditModal.modal}
{machineDeleteModal.modal}
<Group justify="space-between">
<Box></Box>
@ -227,36 +261,11 @@ function MachineDrawer({
actions={[
EditItemAction({
tooltip: t`Edit machine`,
onClick: () => {
openEditApiForm({
title: t`Edit machine`,
url: ApiEndpoints.machine_list,
pk: machinePk,
fields: {
name: {},
active: {}
},
onClose: () => refreshAll()
});
}
onClick: machineEditModal.open
}),
DeleteItemAction({
tooltip: t`Delete machine`,
onClick: () => {
openDeleteApiForm({
title: t`Delete machine`,
successMessage: t`Machine successfully deleted.`,
url: ApiEndpoints.machine_list,
pk: machinePk,
preFormContent: (
<Text>{t`Are you sure you want to remove the machine "${machine?.name}"?`}</Text>
),
onFormSuccess: () => {
refreshTable();
navigate(-1);
}
});
}
onClick: machineDeleteModal.open
}),
{
icon: <IconRefresh />,