InvenTree/src/frontend/src/tables/plugin/PluginListTable.tsx

587 lines
15 KiB
TypeScript

import { Trans, t } from '@lingui/macro';
import {
Alert,
Box,
Card,
Group,
LoadingOverlay,
Stack,
Text,
Title,
Tooltip
} from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import {
IconCircleCheck,
IconCircleX,
IconHelpCircle,
IconInfoCircle,
IconPlaylistAdd,
IconRefresh
} from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ActionButton } from '../../components/buttons/ActionButton';
import { YesNoButton } from '../../components/buttons/YesNoButton';
import { InfoItem } from '../../components/items/InfoItem';
import { DetailDrawer } from '../../components/nav/DetailDrawer';
import { PluginSettingList } from '../../components/settings/SettingList';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useTable } from '../../hooks/UseTable';
import { apiUrl, useServerApiState } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction } from '../RowActions';
export interface PluginI {
pk: number;
key: string;
name: string;
active: boolean;
is_builtin: boolean;
is_sample: boolean;
is_installed: boolean;
is_package: boolean;
package_name: string | null;
meta: {
author: string | null;
description: string | null;
human_name: string | null;
license: string | null;
package_path: string | null;
pub_date: string | null;
settings_url: string | null;
slug: string | null;
version: string | null;
website: string | null;
};
mixins: Record<
string,
{
key: string;
human_name: string;
}
>;
}
export function PluginDrawer({ pluginKey }: Readonly<{ pluginKey: string }>) {
const {
instance: plugin,
instanceQuery: { isFetching, error }
} = useInstance<PluginI>({
endpoint: ApiEndpoints.plugin_list,
hasPrimaryKey: true,
pk: pluginKey,
throwError: true
});
if (!pluginKey || isFetching) {
return <LoadingOverlay visible={true} />;
}
if (!plugin || error) {
return (
<Text>
{(error as any)?.response?.status === 404 ? (
<Trans>Plugin with key {pluginKey} not found</Trans>
) : (
<Trans>An error occurred while fetching plugin details</Trans>
)}
</Text>
);
}
return (
<Stack gap={'xs'}>
<Card withBorder>
<Group justify="left">
<Box></Box>
<Group gap={'xs'}>
{plugin && <PluginIcon plugin={plugin} />}
<Title order={4}>
{plugin?.meta?.human_name ?? plugin?.name ?? '-'}
</Title>
</Group>
</Group>
</Card>
<LoadingOverlay visible={isFetching} overlayProps={{ opacity: 0 }} />
<Card withBorder>
<Stack gap="md">
<Title order={4}>
<Trans>Plugin information</Trans>
</Title>
{plugin.active ? (
<Stack pos="relative" gap="xs">
<InfoItem type="text" name={t`Name`} value={plugin?.name} />
<InfoItem
type="text"
name={t`Description`}
value={plugin?.meta.description}
/>
<InfoItem
type="text"
name={t`Author`}
value={plugin?.meta.author}
/>
<InfoItem
type="text"
name={t`Date`}
value={plugin?.meta.pub_date}
/>
<InfoItem
type="text"
name={t`Version`}
value={plugin?.meta.version}
/>
<InfoItem
type="boolean"
name={t`Active`}
value={plugin?.active}
/>
</Stack>
) : (
<Text c="red">{t`Plugin is not active`}</Text>
)}
</Stack>
</Card>
{plugin.active && (
<Card withBorder>
<Stack gap="md">
<Title order={4}>
<Trans>Package information</Trans>
</Title>
<Stack pos="relative" gap="xs">
{plugin?.is_package && (
<InfoItem
type="text"
name={t`Package Name`}
value={plugin?.package_name}
/>
)}
<InfoItem
type="text"
name={t`Installation Path`}
value={plugin?.meta.package_path}
/>
<InfoItem
type="boolean"
name={t`Builtin`}
value={plugin?.is_builtin}
/>
<InfoItem
type="boolean"
name={t`Package`}
value={plugin?.is_package}
/>
</Stack>
</Stack>
</Card>
)}
{plugin && plugin?.active && (
<Card withBorder>
<Stack gap="md">
<Title order={4}>
<Trans>Plugin settings</Trans>
</Title>
<PluginSettingList pluginKey={pluginKey} />
</Stack>
</Card>
)}
</Stack>
);
}
/**
* Construct an indicator icon for a single plugin
*/
function PluginIcon({ plugin }: Readonly<{ plugin: PluginI }>) {
if (plugin?.is_installed) {
if (plugin?.active) {
return (
<Tooltip label={t`Plugin is active`}>
<IconCircleCheck color="green" />
</Tooltip>
);
} else {
return (
<Tooltip label={t`Plugin is inactive`}>
<IconCircleX color="red" />
</Tooltip>
);
}
} else {
return (
<Tooltip label={t`Plugin is not installed`}>
<IconHelpCircle />
</Tooltip>
);
}
}
/**
* Table displaying list of available plugins
*/
export default function PluginListTable() {
const table = useTable('plugin');
const navigate = useNavigate();
const user = useUserState();
const [pluginsEnabled, plugins_install_disabled] = useServerApiState(
(state) => [
state.server.plugins_enabled,
state.server.plugins_install_disabled
]
);
const pluginTableColumns: TableColumn[] = useMemo(
() => [
{
accessor: 'name',
title: t`Plugin`,
sortable: true,
render: function (record: any) {
if (!record) {
return;
}
return (
<Group justify="left">
<PluginIcon plugin={record} />
<Text>{record.name}</Text>
</Group>
);
}
},
{
accessor: 'active',
sortable: true,
title: t`Active`,
render: (record: any) => <YesNoButton value={record.active} />
},
{
accessor: 'meta.description',
title: t`Description`,
sortable: false,
render: function (record: any) {
if (record.active) {
return record?.meta.description;
} else {
return (
<Text
style={{ fontStyle: 'italic' }}
size="sm"
>{t`Description not available`}</Text>
);
}
}
},
{
accessor: 'meta.version',
title: t`Version`,
sortable: false
// TODO: Display date information if available
},
{
accessor: 'meta.author',
title: 'Author',
sortable: false
}
],
[]
);
const [selectedPlugin, setSelectedPlugin] = useState<string>('');
const [activate, setActivate] = useState<boolean>(false);
const activateModalContent = useMemo(() => {
return (
<Stack gap="xs">
<Alert
color={activate ? 'green' : 'red'}
icon={<IconCircleCheck />}
title={
activate
? t`Confirm plugin activation`
: t`Confirm plugin deactivation`
}
>
<Text>
{activate
? t`The selected plugin will be activated`
: t`The selected plugin will be deactivated`}
</Text>
</Alert>
</Stack>
);
}, [activate]);
const activatePluginModal = useEditApiFormModal({
title: t`Activate Plugin`,
url: ApiEndpoints.plugin_activate,
pathParams: { key: selectedPlugin },
preFormContent: activateModalContent,
fetchInitialData: false,
method: 'POST',
successMessage: activate
? `The plugin was activated`
: `The plugin was deactivated`,
fields: {
active: {
value: activate,
hidden: true
}
},
table: table
});
// Determine available actions for a given plugin
const rowActions = useCallback(
(record: any): RowAction[] => {
// Only superuser can perform plugin actions
if (!user.isSuperuser()) {
return [];
}
let actions: RowAction[] = [];
if (!record.is_builtin && record.is_installed) {
if (record.active) {
actions.push({
title: t`Deactivate`,
color: 'red',
icon: <IconCircleX />,
onClick: () => {
setSelectedPlugin(record.key);
setActivate(false);
activatePluginModal.open();
}
});
} else {
actions.push({
title: t`Activate`,
color: 'green',
icon: <IconCircleCheck />,
onClick: () => {
setSelectedPlugin(record.key);
setActivate(true);
activatePluginModal.open();
}
});
}
}
// Active 'package' plugins can be updated
if (record.active && record.is_package && record.package_name) {
actions.push({
title: t`Update`,
color: 'blue',
icon: <IconRefresh />,
onClick: () => {
setPluginPackage(record.package_name);
installPluginModal.open();
}
});
}
// Inactive 'package' plugins can be uninstalled
if (
!record.active &&
record.is_installed &&
record.is_package &&
record.package_name
) {
actions.push({
title: t`Uninstall`,
color: 'red',
icon: <IconCircleX />,
onClick: () => {
setSelectedPlugin(record.key);
uninstallPluginModal.open();
},
disabled: plugins_install_disabled || false
});
}
// Uninstalled 'package' plugins can be deleted
if (!record.is_installed) {
actions.push({
title: t`Delete`,
color: 'red',
icon: <IconCircleX />,
onClick: () => {
setSelectedPlugin(record.key);
deletePluginModal.open();
}
});
}
return actions;
},
[user, pluginsEnabled]
);
const [pluginPackage, setPluginPackage] = useState<string>('');
const installPluginModal = useCreateApiFormModal({
title: t`Install plugin`,
url: ApiEndpoints.plugin_install,
timeout: 30000,
fields: {
packagename: {},
url: {},
version: {},
confirm: {}
},
initialData: {
packagename: pluginPackage
},
closeOnClickOutside: false,
submitText: t`Install`,
successMessage: t`Plugin installed successfully`,
table: table
});
const uninstallPluginModal = useEditApiFormModal({
title: t`Uninstall Plugin`,
url: ApiEndpoints.plugin_uninstall,
pathParams: { key: selectedPlugin },
fetchInitialData: false,
timeout: 30000,
fields: {
delete_config: {}
},
preFormContent: (
<Alert
color="red"
icon={<IconInfoCircle />}
title={t`Confirm plugin uninstall`}
>
<Stack gap="xs">
<Text>{t`The selected plugin will be uninstalled.`}</Text>
<Text>{t`This action cannot be undone.`}</Text>
</Stack>
</Alert>
),
successMessage: t`Plugin uninstalled successfully`,
table: table
});
const deletePluginModal = useDeleteApiFormModal({
url: ApiEndpoints.plugin_list,
pathParams: { key: selectedPlugin },
title: t`Delete Plugin`,
preFormWarning: t`Deleting this plugin configuration will remove all associated settings and data. Are you sure you want to delete this plugin?`,
table: table
});
const reloadPlugins = useCallback(() => {
api
.post(apiUrl(ApiEndpoints.plugin_reload), {
full_reload: true,
force_reload: true,
collect_plugins: true
})
.then(() => {
showNotification({
title: t`Plugins reloaded`,
message: t`Plugins were reloaded successfully`,
color: 'green'
});
table.refreshTable();
});
}, []);
// Custom table actions
const tableActions = useMemo(() => {
if (!user.isSuperuser() || !pluginsEnabled) {
return [];
}
return [
<ActionButton
key="reload"
color="green"
icon={<IconRefresh />}
tooltip={t`Reload Plugins`}
onClick={reloadPlugins}
/>,
<ActionButton
key="install"
color="green"
icon={<IconPlaylistAdd />}
tooltip={t`Install Plugin`}
onClick={() => {
setPluginPackage('');
installPluginModal.open();
}}
disabled={plugins_install_disabled || false}
/>
];
}, [user, pluginsEnabled]);
return (
<>
{activatePluginModal.modal}
{installPluginModal.modal}
{uninstallPluginModal.modal}
{deletePluginModal.modal}
<DetailDrawer
title={t`Plugin Detail`}
size={'50%'}
renderContent={(pluginKey) => {
if (!pluginKey) return;
return <PluginDrawer pluginKey={pluginKey} />;
}}
/>
<InvenTreeTable
url={apiUrl(ApiEndpoints.plugin_list)}
tableState={table}
columns={pluginTableColumns}
props={{
enableDownload: false,
rowActions: rowActions,
onRowClick: (plugin) => navigate(`${plugin.key}/`),
tableActions: tableActions,
tableFilters: [
{
name: 'active',
label: t`Active`,
type: 'boolean'
},
{
name: 'builtin',
label: t`Builtin`,
type: 'boolean'
},
{
name: 'sample',
label: t`Sample`,
type: 'boolean'
},
{
name: 'installed',
label: t`Installed`,
type: 'boolean'
}
]
}}
/>
</>
);
}