2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-13 22:21:37 +00:00

Machine registry improvements (#10150)

* Add wrapper function for machine registry

* Decorate entrypoint functions

* Docstrings

* Fix for boolean setting

* Add playwright tests

* Use proper entrypoints

* Ensure settings are fetched correctly

* Prevent recursion of machine registry decorator

* Fix machine status display

* Enhanced warning msg

* Add simple machine sample printer

* Adds playwright tests for machine UI

* re-throw exception

* Define 'machine' plugin mixin class

* Adjust machine discovery

* Use plugin mixins for registering machine types and drivers

* Adjust unit test

* Remove plugin static files when deactivating

* Force machine reload when plugin registry changes

* Add plugins specific to testing framework

* Add test for plugin loading sequence

* Add session caching

- Significantly reduce DB hits

* Enhanced unit testing and test plugins

* Refactor unit tests

* Further unit test fixes

* Adjust instance rendering

* Display table of available drivers

* Cleanup

* ADjust unit test

* Tweak unit test

* Add docs on new mixin type

* Tweak machine overview docs

* Tweak playwright tests

* Additional unit test

* Add unit test for calling machine func

* Enhanced playwright tests

* Account for database not being ready
This commit is contained in:
Oliver
2025-08-20 23:00:39 +10:00
committed by GitHub
parent ed31503d3b
commit bd9c52eeaf
40 changed files with 1095 additions and 425 deletions

View File

@@ -9,7 +9,7 @@ export function RenderProjectCode({
instance && (
<RenderInlineModel
primary={instance.code}
secondary={instance.description}
suffix={instance.description}
/>
)
);

View File

@@ -67,7 +67,7 @@ export function RenderPartCategory(
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard
value={<Text size='sm'>{instance.description}</Text>}
value={<Text size='xs'>{instance.description}</Text>}
position='bottom-end'
zIndex={10000}
icon='sitemap'

View File

@@ -12,8 +12,8 @@ export function RenderPlugin({
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.meta?.description}
suffix={
suffix={instance.meta?.description}
secondary={
!instance.active && <Badge size='sm' color='red'>{t`Inactive`}</Badge>
}
/>

View File

@@ -8,10 +8,7 @@ export function RenderReportTemplate({
instance: any;
}>): ReactNode {
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
/>
<RenderInlineModel primary={instance.name} suffix={instance.description} />
);
}
@@ -21,9 +18,6 @@ export function RenderLabelTemplate({
instance: any;
}>): ReactNode {
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
/>
<RenderInlineModel primary={instance.name} suffix={instance.description} />
);
}

View File

@@ -74,14 +74,16 @@ export function getStatusCodes(
const statusCodeList = useGlobalStatusState.getState().status;
if (statusCodeList === undefined) {
console.log('StatusRenderer: statusCodeList is undefined');
console.warn('StatusRenderer: statusCodeList is undefined');
return null;
}
const statusCodes = statusCodeList[type];
if (statusCodes === undefined) {
console.log('StatusRenderer: statusCodes is undefined');
console.warn(
`StatusRenderer: statusCodes is undefined for model '${type}'`
);
return null;
}
@@ -175,7 +177,9 @@ export const StatusRenderer = ({
}
if (statusCodes === undefined || statusCodes === null) {
console.warn('StatusRenderer: statusCodes is undefined');
console.warn(
`StatusRenderer: statusCodes is undefined for model '${type}'`
);
return null;
}

View File

@@ -29,7 +29,7 @@ export function RenderStockLocation(
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard
value={<Text size='sm'>{instance.description}</Text>}
value={<Text size='xs'>{instance.description}</Text>}
position='bottom-end'
zIndex={10000}
icon='sitemap'
@@ -75,7 +75,7 @@ export function RenderStockLocationType({
<RenderInlineModel
primary={instance.name}
prefix={instance.icon && <ApiIcon name={instance.icon} />}
secondary={`${instance.description} (${instance.location_count})`}
suffix={`${instance.description} (${instance.location_count})`}
/>
);
}

View File

@@ -40,7 +40,7 @@ export function RenderUser({
}
suffix={
<Group gap='xs'>
<Text size='sm'>
<Text size='xs'>
{instance.first_name} {instance.last_name}
</Text>
<IconUser size={16} />

View File

@@ -18,7 +18,10 @@ import { apiUrl } from '@lib/functions/Api';
import { api } from '../../../../App';
import { StylishText } from '../../../../components/items/StylishText';
import { MachineListTable } from '../../../../tables/machine/MachineListTable';
import { MachineTypeListTable } from '../../../../tables/machine/MachineTypeTable';
import {
MachineDriverTable,
MachineTypeListTable
} from '../../../../tables/machine/MachineTypeTable';
interface MachineRegistryStatusI {
registry_errors: { message: string }[];
@@ -42,7 +45,7 @@ export default function MachineManagementPanel() {
}, [registryStatus]);
return (
<Accordion multiple defaultValue={['machinelist', 'machinetypes']}>
<Accordion multiple defaultValue={['machinelist']}>
<Accordion.Item value='machinelist'>
<Accordion.Control>
<StylishText size='lg'>{t`Machines`}</StylishText>
@@ -51,6 +54,14 @@ export default function MachineManagementPanel() {
<MachineListTable props={{}} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='drivertypes'>
<Accordion.Control>
<StylishText size='lg'>{t`Machine Drivers`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<MachineDriverTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='machinetypes'>
<Accordion.Control>
<StylishText size='lg'>{t`Machine Types`}</StylishText>

View File

@@ -210,7 +210,7 @@ export const createMachineSettingsState = ({
}: CreateMachineSettingStateProps) => {
const pathParams: PathParams = { machine, config_type: configType };
return createStore<SettingsStateProps>()((set, get) => ({
const store = createStore<SettingsStateProps>()((set, get) => ({
settings: [],
lookup: {},
loaded: false,
@@ -255,6 +255,12 @@ export const createMachineSettingsState = ({
return isTrue(value);
}
}));
useEffect(() => {
store.getState().fetchSettings();
}, [machine, configType]);
return store;
};
/*

View File

@@ -1,6 +1,7 @@
import { t } from '@lingui/core/macro';
import {
Accordion,
Alert,
Badge,
Box,
Card,
@@ -23,9 +24,11 @@ import { AddItemButton } from '@lib/components/AddItemButton';
import { YesNoButton } from '@lib/components/YesNoButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import type { TableColumn } from '@lib/types/Tables';
import { RowDeleteAction, RowEditAction } from '@lib/index';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import type { InvenTreeTableProps } from '@lib/types/Tables';
import { Trans } from '@lingui/react/macro';
import { api } from '../../App';
import {
DeleteItemAction,
EditItemAction,
@@ -100,6 +103,32 @@ function MachineStatusIndicator({ machine }: Readonly<{ machine: MachineI }>) {
);
}
/**
* Helper function to restart a machine with the provided ID
*/
function restartMachine({
machinePk,
callback
}: {
machinePk: string;
callback?: () => void;
}) {
api
.post(
apiUrl(ApiEndpoints.machine_restart, undefined, {
machine: machinePk
})
)
.then(() => {
notifications.show({
message: t`Machine restarted`,
color: 'green',
icon: <IconCheck size='1rem' />
});
callback?.();
});
}
export function useMachineTypeDriver({
includeTypes = true,
includeDrivers = true
@@ -192,26 +221,6 @@ function MachineDrawer({
refreshTable();
}, [refetch, refreshTable]);
const restartMachine = useCallback(
(machinePk: string) => {
api
.post(
apiUrl(ApiEndpoints.machine_restart, undefined, {
machine: machinePk
})
)
.then(() => {
refreshAll();
notifications.show({
message: t`Machine restarted`,
color: 'green',
icon: <IconCheck size='1rem' />
});
});
},
[refreshAll]
);
const machineEditModal = useEditApiFormModal({
title: t`Edit machine`,
url: ApiEndpoints.machine_list,
@@ -232,7 +241,9 @@ function MachineDrawer({
url: ApiEndpoints.machine_list,
pk: machinePk,
preFormContent: (
<Text>{t`Are you sure you want to remove the machine "${machine?.name ?? 'unknown'}"?`}</Text>
<Alert color='red'>
{t`Are you sure you want to remove this machine?`}
</Alert>
),
onFormSuccess: () => {
refreshTable();
@@ -245,7 +256,6 @@ function MachineDrawer({
<Stack gap='xs'>
{machineEditModal.modal}
{machineDeleteModal.modal}
<Group justify='space-between'>
<Group>
{machine && <MachineStatusIndicator machine={machine} />}
@@ -279,7 +289,14 @@ function MachineDrawer({
indicator: machine?.restart_required
? { color: 'red' }
: undefined,
onClick: () => machine && restartMachine(machine?.pk)
onClick: () => {
if (machine) {
restartMachine({
machinePk: machine?.pk,
callback: refreshAll
});
}
}
}
]}
/>
@@ -487,9 +504,7 @@ export function MachineListTable({
accessor: 'status',
sortable: false,
render: (record) => {
const renderer = TableStatusRenderer(
`MachineStatus__${record.status_model}` as any
);
const renderer = TableStatusRenderer(`${record.status_model}` as any);
if (renderer && record.status !== -1) {
return renderer(record);
}
@@ -552,6 +567,65 @@ export function MachineListTable({
}
});
const [selectedMachinePk, setSelectedMachinePk] = useState<
string | undefined
>(undefined);
const deleteMachineForm = useDeleteApiFormModal({
title: t`Delete Machine`,
successMessage: t`Machine successfully deleted.`,
url: ApiEndpoints.machine_list,
pk: selectedMachinePk,
preFormContent: (
<Alert color='red'>
{t`Are you sure you want to remove this machine?`}
</Alert>
),
table: table
});
const editMachineForm = useEditApiFormModal({
title: t`Edit Machine`,
url: ApiEndpoints.machine_list,
pk: selectedMachinePk,
fields: {
name: {},
active: {}
},
table: table
});
const rowActions = useCallback((record: any): RowAction[] => {
return [
{
icon: <IconRefresh />,
title: t`Restart Machine`,
onClick: () => {
restartMachine({
machinePk: record.pk,
callback: () => {
table.refreshTable();
}
});
}
},
RowEditAction({
title: t`Edit machine`,
onClick: () => {
setSelectedMachinePk(record.pk);
editMachineForm.open();
}
}),
RowDeleteAction({
title: t`Delete Machine`,
onClick: () => {
setSelectedMachinePk(record.pk);
deleteMachineForm.open();
}
})
];
}, []);
const tableActions = useMemo(() => {
return [
<AddItemButton
@@ -568,6 +642,8 @@ export function MachineListTable({
return (
<>
{createMachineForm.modal}
{editMachineForm.modal}
{deleteMachineForm.modal}
{renderMachineDrawer && (
<DetailDrawer
title={t`Machine Detail`}
@@ -596,7 +672,8 @@ export function MachineListTable({
? `machine-${machine.pk}/`
: `../machine-${machine.pk}/`
),
tableActions,
rowActions: rowActions,
tableActions: tableActions,
params: {
...props.params
},

View File

@@ -50,6 +50,56 @@ export interface MachineDriverI {
driver_errors: string[];
}
export function MachineDriverTable({
machineType,
prefix
}: {
machineType?: string;
prefix?: string;
}) {
const navigate = useNavigate();
const table = useTable('machine-drivers');
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'name',
title: t`Name`
},
DescriptionColumn({}),
{
accessor: 'machine_type',
title: t`Driver Type`
},
BooleanColumn({
accessor: 'is_builtin',
title: t`Builtin driver`
})
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiEndpoints.machine_driver_list)}
tableState={table}
columns={tableColumns}
props={{
enableDownload: false,
enableSearch: false,
onRowClick: (machine) => {
navigate(`${prefix ?? '.'}/driver-${machine.slug}/`);
},
dataFormatter: (data: any) => {
if (machineType) {
return data.filter((d: any) => d.machine_type === machineType);
}
return data;
}
}}
/>
);
}
function MachineTypeDrawer({
machineTypeSlug
}: Readonly<{ machineTypeSlug: string }>) {
@@ -63,23 +113,6 @@ function MachineTypeDrawer({
[machineTypes, machineTypeSlug]
);
const table = useTable('machineDrivers');
const machineDriverTableColumns = useMemo<TableColumn<MachineDriverI>[]>(
() => [
{
accessor: 'name',
title: t`Name`
},
DescriptionColumn({}),
BooleanColumn({
accessor: 'is_builtin',
title: t`Builtin driver`
})
],
[]
);
return (
<>
<Stack>
@@ -162,22 +195,7 @@ function MachineTypeDrawer({
</Accordion.Control>
<Accordion.Panel>
<Card withBorder>
<InvenTreeTable
url={apiUrl(ApiEndpoints.machine_driver_list)}
tableState={table}
columns={machineDriverTableColumns}
props={{
dataFormatter: (data: any) => {
return data.filter(
(d: any) => d.machine_type === machineTypeSlug
);
},
enableDownload: false,
enableSearch: false,
onRowClick: (machine) =>
navigate(`../driver-${machine.slug}/`)
}}
/>
<MachineDriverTable machineType={machineTypeSlug} prefix='..' />
</Card>
</Accordion.Panel>
</Accordion.Item>
@@ -379,7 +397,7 @@ export function MachineTypeListTable({
...props,
enableDownload: false,
enableSearch: false,
onRowClick: (machine) => navigate(`type-${machine.slug}/`),
onRowClick: (machine) => navigate(`./type-${machine.slug}/`),
params: {
...props.params
}

View File

@@ -0,0 +1,113 @@
import test from 'playwright/test';
import { clickOnRowMenu, navigate } from './helpers';
import { doCachedLogin } from './login';
import { setPluginState } from './settings';
test('Machines - Admin Panel', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree',
url: 'settings/admin/machine'
});
await page.getByRole('button', { name: 'Machines' }).click();
await page.getByRole('button', { name: 'Machine Drivers' }).click();
await page.getByRole('button', { name: 'Machine Types' }).click();
await page.getByRole('button', { name: 'Machine Errors' }).click();
await page.getByText('There are no machine registry errors').waitFor();
});
test('Machines - Activation', async ({ browser, request }) => {
const page = await doCachedLogin(browser, {
username: 'admin',
password: 'inventree',
url: 'settings/admin/machine'
});
// Ensure that the sample machine plugin is enabled
await setPluginState({
request,
plugin: 'sample-printer-machine-plugin',
state: true
});
await page.reload();
await page.getByRole('button', { name: 'action-button-add-machine' }).click();
await page
.getByRole('textbox', { name: 'text-field-name' })
.fill('my-dummy-machine');
await page
.getByRole('textbox', { name: 'choice-field-machine_type' })
.fill('label');
await page.getByRole('option', { name: 'Label Printer' }).click();
await page.getByRole('textbox', { name: 'choice-field-driver' }).click();
await page
.getByRole('option', { name: 'Sample Label Printer Driver' })
.click();
await page.getByRole('button', { name: 'Submit' }).click();
// Creating the new machine opens the "machine drawer"
// Check for "machine type" settings
await page.getByText('Scope the printer to a specific location').waitFor();
// Check for "machine driver" settings
await page.getByText('Custom string for connecting').waitFor();
// Edit the available setting
await page.getByRole('button', { name: 'edit-setting-CONNECTION' }).click();
await page
.getByRole('textbox', { name: 'text-field-value' })
.fill('a new value');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Setting CONNECTION updated successfully').waitFor();
// Close the drawer
await page.getByRole('banner').getByRole('button').first().click();
const cell = await page.getByRole('cell', { name: 'my-dummy-machine' });
// Let's restart the machine now
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Edit' }).waitFor();
await page.getByRole('menuitem', { name: 'Restart' }).click();
await page.getByText('Machine restarted').waitFor();
// Let's print something with the machine
await navigate(page, 'stock/location/1/stock-items');
await page.getByRole('checkbox', { name: 'Select all records' }).click();
await page
.getByRole('tabpanel', { name: 'Stock Items' })
.getByLabel('action-menu-printing-actions')
.click();
await page
.getByRole('menuitem', {
name: 'action-menu-printing-actions-print-labels'
})
.click();
await page.getByLabel('related-field-plugin').fill('machine');
await page.getByText('InvenTreeLabelMachine').click();
await page
.getByRole('textbox', { name: 'choice-field-machine' })
.fill('dummy');
await page.getByRole('option', { name: 'my-dummy-machine' }).click();
await page
.getByRole('button', { name: 'Print', exact: true })
.first()
.click();
await page.getByText('Process completed successfully').waitFor();
await navigate(page, 'settings/admin/machine/');
// Finally, delete the machine configuration
await clickOnRowMenu(cell);
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await page.getByText('Machine successfully deleted.').waitFor();
});