diff --git a/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/installed/InstalledPluginFilters.tsx b/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/installed/InstalledPluginFilters.tsx index 6b4592d6f79..a1ce0ff3ee1 100644 --- a/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/installed/InstalledPluginFilters.tsx +++ b/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/installed/InstalledPluginFilters.tsx @@ -9,15 +9,13 @@ import { } from '@/app/(protected)/plugins/_lib/PluginTable'; import _ from 'lodash'; +type PluginFilterFunc = (row: PluginRow) => boolean; + type InstalledPluginFilterProps = { setDisplayedRowsCallback: (rows: PluginRow[]) => void; setIsFilteringCallback: (isFiltering: boolean) => void; }; -export type FilterProps = { - setFiltersCallback: (filters: any) => void; -}; - export const defaultSearchableColumns = [ 'name', 'pluginType', @@ -31,6 +29,15 @@ const InstalledPluginFilters = (props: InstalledPluginFilterProps) => { const { data: installedPlugins } = useGetInstalledPluginsQuery(); const [filters, setFilters] = useState({}); + const setFilterCallback = ( + filterName: string, + filterFunc: PluginFilterFunc + ) => { + setFilters((prevState) => { + return { ...prevState, [filterName]: filterFunc }; + }); + }; + const filterRows = (rows: PluginRow[]): PluginRow[] => { setIsFilteringCallback(true); let filteredRows = _.cloneDeep(rows); @@ -62,7 +69,7 @@ const InstalledPluginFilters = (props: InstalledPluginFilterProps) => { item sx={{ alignItems: 'flex-end', display: 'flex' }}> @@ -71,7 +78,7 @@ const InstalledPluginFilters = (props: InstalledPluginFilterProps) => { item sx={{ alignItems: 'flex-end', display: 'flex' }}> diff --git a/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/installed/PluginUpgradeButton.tsx b/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/installed/PluginUpgradeButton.tsx new file mode 100644 index 00000000000..51d1f46efdb --- /dev/null +++ b/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/installed/PluginUpgradeButton.tsx @@ -0,0 +1,89 @@ +import { GridActionsCellItem } from '@mui/x-data-grid'; +import FileDownloadIcon from '@mui/icons-material/FileDownload'; +import React from 'react'; +import { + useGetLatestPluginVersionQuery, + useInstallPluginMutation +} from '@/redux/features/api/agentPlugins/agentPluginEndpoints'; +import MonkeyLoadingIcon from '@/_components/icons/MonkeyLoadingIcon'; +import DownloadDoneIcon from '@mui/icons-material/DownloadDone'; +import { PluginId, PluginInfo } from '@/redux/features/api/agentPlugins/types'; + +const PluginUpgradeButton = (props: PluginInfo) => { + const { pluginType, pluginName, pluginVersion, pluginId } = props; + + const [ + upgradePlugin, + { + isLoading: isUpgrading, + isSuccess: isUpgradeSuccessful, + reset: resetUpgradePlugin + } + ] = useInstallPluginMutation({ fixedCacheKey: pluginName + pluginType }); + const { data: latestPluginVersion, isLoading: isLoadingLatestVersion } = + useGetLatestPluginVersionQuery({ + pluginType: pluginType, + pluginName: pluginName + }); + + const isUpgradable = React.useMemo(() => { + if (!latestPluginVersion) { + return false; + } + const upgradeAvailable = latestPluginVersion !== pluginVersion; + upgradeAvailable && isUpgradeSuccessful && resetUpgradePlugin(); + return upgradeAvailable; + }, [latestPluginVersion, pluginVersion]); + + const onUpgradeClick = () => { + upgradePlugin({ + pluginVersion: String(latestPluginVersion), + pluginName: pluginName, + pluginType: pluginType, + pluginId: pluginId + }); + }; + + if (isUpgrading) { + return UpgradeInProgressButton(pluginId); + } else if (isUpgradeSuccessful) { + return UpgradeDoneButton(pluginId); + } else if (isLoadingLatestVersion || !isUpgradable) { + return; + } else { + return UpgradeButton(pluginId, onUpgradeClick); + } +}; + +const UpgradeButton = (pluginId: PluginId, onUpgradeClick: () => void) => { + return ( + } + label="Download" + onClick={onUpgradeClick} + /> + ); +}; + +const UpgradeInProgressButton = (pluginId: PluginId) => { + return ( + } + label="Upgrading" + /> + ); +}; + +const UpgradeDoneButton = (pluginId: PluginId) => { + return ( + } + label="Upgrade Complete" + /> + ); +}; + +export default PluginUpgradeButton; diff --git a/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/installed/page.tsx b/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/installed/page.tsx index 87f46417322..262c73e850a 100644 --- a/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/installed/page.tsx +++ b/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/installed/page.tsx @@ -9,6 +9,7 @@ import PluginTable, { import Grid from '@mui/material/Grid'; import { InstalledPlugin } from '@/redux/features/api/agentPlugins/types'; import InstalledPluginFilters from '@/app/(protected)/plugins/installed/InstalledPluginFilters'; +import PluginUpgradeButton from '@/app/(protected)/plugins/installed/PluginUpgradeButton'; export default function InstalledPluginsPage() { const { @@ -19,9 +20,15 @@ export default function InstalledPluginsPage() { const [displayedRows, setDisplayedRows] = React.useState([]); const [isLoadingRows, setIsLoadingRows] = React.useState(false); - // eslint-disable-next-line @typescript-eslint/no-unused-vars const getUpgradeAction = (plugin: InstalledPlugin) => { - return []; + return ( + + ); }; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -35,7 +42,7 @@ export default function InstalledPluginsPage() { (installedPlugin) => installedPlugin.id === row.id ); if (!plugin) return []; - return [...getUpgradeAction(plugin), ...getUninstallAction(plugin)]; + return [getUpgradeAction(plugin)]; }; const getOverlayMessage = () => { diff --git a/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/upload/page.tsx b/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/upload/page.tsx index ffb269b6dea..5abb4005e6b 100644 --- a/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/upload/page.tsx +++ b/monkey/monkey_island/cc/next_ui/src/app/(protected)/plugins/upload/page.tsx @@ -13,14 +13,15 @@ import MonkeyFileUpload, { } from '@/_components/file-upload/MonkeyFileUpload'; import { useUploadPluginMutation } from '@/redux/features/api/agentPlugins/agentPluginEndpoints'; import MonkeyAlert from '@/_components/alerts/MonkeyAlert'; +import { Severity } from '@/_components/lib/severity'; const UploadNewPlugin = () => { const [upload, { isError, error, isLoading, isSuccess }] = useUploadPluginMutation(); - const [plugin, setPlugin] = useState(null); + const [plugin, setPlugin] = useState(null); const [showSuccessAlert, setShowSuccessAlert] = useState(false); const [pluginName, setPluginName] = useState(''); - const [errors, setErrors] = useState([]); + const [errors, setErrors] = useState([]); const uploadStatus = useMemo(() => { if (plugin !== null) { @@ -56,8 +57,10 @@ const UploadNewPlugin = () => { if (acceptedPlugin?.length) { const reader = new FileReader(); reader.onload = (e) => { - if (e.target.readyState === FileReader.DONE) { - const binaryPlugin = new Uint8Array(e.target.result); + if (e.target?.readyState === FileReader.DONE) { + const binaryPlugin = new Uint8Array( + e.target.result as ArrayBuffer + ); setPlugin(binaryPlugin); setPluginName(Object.assign(acceptedPlugin?.[0]).name); } @@ -123,7 +126,7 @@ const UploadNewPlugin = () => { {showSuccessAlert && ( setShowSuccessAlert(false)}> '{pluginName}' was successfully installed! @@ -131,7 +134,9 @@ const UploadNewPlugin = () => { )} {showErrors && ( - setErrors([])}> + setErrors([])}> Error uploading Plugin Tar
    {errors.map((error, index) => ( diff --git a/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/agentPluginEndpoints.tsx b/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/agentPluginEndpoints.tsx index c481ee6540c..2500e8f67c0 100644 --- a/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/agentPluginEndpoints.tsx +++ b/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/agentPluginEndpoints.tsx @@ -10,6 +10,7 @@ import { PluginTar } from '@/redux/features/api/agentPlugins/types'; import { + parsePluginFromResponse, parsePluginManifestResponse, parsePluginMetadataResponse } from '@/redux/features/api/agentPlugins/responseParsers'; @@ -34,6 +35,27 @@ export const agentPluginEndpoints = islandApiSlice.injectEndpoints({ return parsePluginMetadataResponse(response.plugins); } }), + getLatestPluginVersion: builder.query< + string, + { pluginType: string; pluginName: string } + >({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + query: ({ pluginType, pluginName }) => ({ + url: BackendEndpoints.PLUGIN_INDEX, + method: HTTP_METHODS.GET + }), + transformResponse: ( + response: { + plugins: PluginMetadataResponse; + }, + _, + { pluginType, pluginName } + ): string => { + const pluginFromResponse = + response.plugins[pluginType][pluginName].slice(-1)[0]; + return parsePluginFromResponse(pluginFromResponse)['version']; + } + }), getInstalledPlugins: builder.query({ query: () => ({ url: BackendEndpoints.PLUGIN_MANIFESTS, @@ -43,7 +65,8 @@ export const agentPluginEndpoints = islandApiSlice.injectEndpoints({ response: PluginManifestResponse ): InstalledPlugin[] => { return parsePluginManifestResponse(response); - } + }, + providesTags: ['InstalledAgentPlugins'] }), installPlugin: builder.mutation({ query: (pluginInfo: PluginInfo) => ({ @@ -54,7 +77,8 @@ export const agentPluginEndpoints = islandApiSlice.injectEndpoints({ name: pluginInfo.pluginName, version: pluginInfo.pluginVersion } - }) + }), + invalidatesTags: ['InstalledAgentPlugins'] }), uploadPlugin: builder.mutation({ query: (pluginTar: PluginTar) => ({ @@ -62,7 +86,20 @@ export const agentPluginEndpoints = islandApiSlice.injectEndpoints({ method: HTTP_METHODS.PUT, headers: { 'Content-Type': 'application/octet-stream' }, body: pluginTar - }) + }), + invalidatesTags: ['InstalledAgentPlugins'] + }), + upgradePlugin: builder.mutation({ + query: (pluginInfo: PluginInfo) => ({ + url: BackendEndpoints.PLUGIN_INSTALL, + method: HTTP_METHODS.PUT, + body: { + plugin_type: pluginInfo.pluginType, + name: pluginInfo.pluginName, + version: pluginInfo.pluginVersion + } + }), + invalidatesTags: ['InstalledAgentPlugins'] }) }) }); @@ -71,5 +108,6 @@ export const { useGetAvailablePluginsQuery, useGetInstalledPluginsQuery, useInstallPluginMutation, - useUploadPluginMutation + useUploadPluginMutation, + useGetLatestPluginVersionQuery } = agentPluginEndpoints; diff --git a/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/responseParsers.ts b/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/responseParsers.ts index 69e2af8766b..d44e6a6e6ab 100644 --- a/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/responseParsers.ts +++ b/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/responseParsers.ts @@ -29,7 +29,7 @@ export const parsePluginMetadataResponse = ( return plugins; }; -const parsePluginFromResponse = ( +export const parsePluginFromResponse = ( unparsedPlugin: PluginMetadata ): AvailablePlugin => { return { diff --git a/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/types.ts b/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/types.ts index 066b98453c6..c27db46bc25 100644 --- a/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/types.ts +++ b/monkey/monkey_island/cc/next_ui/src/redux/features/api/agentPlugins/types.ts @@ -1,5 +1,7 @@ +export type PluginId = string; + export type AgentPlugin = { - id: string; + id: PluginId; name: string; pluginType: string; description: string; @@ -55,7 +57,7 @@ export type PluginInfo = { pluginType: string; pluginName: string; pluginVersion: string; - pluginId: string; + pluginId: PluginId; }; export type PluginTar = Uint8Array; diff --git a/monkey/monkey_island/cc/next_ui/src/redux/features/api/islandApiSlice.ts b/monkey/monkey_island/cc/next_ui/src/redux/features/api/islandApiSlice.ts index e4fc356ec42..eb7233d7855 100644 --- a/monkey/monkey_island/cc/next_ui/src/redux/features/api/islandApiSlice.ts +++ b/monkey/monkey_island/cc/next_ui/src/redux/features/api/islandApiSlice.ts @@ -49,6 +49,7 @@ const getIslandBaseQuery = async ( export const islandApiSlice = createApi({ reducerPath: 'islandApi', + tagTypes: ['InstalledAgentPlugins'], baseQuery: getIslandBaseQuery, endpoints: () => ({}) });