diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index 106dc3c392..5972fbedfa 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -27,6 +27,7 @@ import { initializeFileTypeIcons } from "@fluentui/react-file-type-icons"; import { CostResource } from "./models/costs"; import { CostsContext } from "./contexts/CostsContext"; import { LoadingState } from "./models/loadingState"; +import Admin from "./components/shared/admin/Admin"; export const App: React.FunctionComponent = () => { const [appRoles, setAppRoles] = useState([] as Array); @@ -124,6 +125,7 @@ export const App: React.FunctionComponent = () => { > } /> + } /> { const [showOperations, setShowOperations] = useState(false); + const [showTemplates, setShowTemplates] = useState(false); return (

Admin

Warning: These admin functions are advanced and experimental, proceed with caution.

- {!showOperations && ( + {!showOperations && !showTemplates && ( {/* Future admin actions should be added here as buttons */} setShowOperations(true)} /> + setShowTemplates(true)} /> {/* Example placeholder for future admin functions */} )} {showOperations && setShowOperations(false)} />} + {showTemplates && setShowTemplates(false)} />}
); }; diff --git a/ui/app/src/components/shared/admin/Templates.test.tsx b/ui/app/src/components/shared/admin/Templates.test.tsx new file mode 100644 index 0000000000..aac849440f --- /dev/null +++ b/ui/app/src/components/shared/admin/Templates.test.tsx @@ -0,0 +1,113 @@ + +import { vi } from 'vitest'; +import * as useAuthApiCall from '../../../hooks/useAuthApiCall'; + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Templates from './Templates'; +import { ResourceTemplate } from '../../../models/resourceTemplate'; + +const mockApiCall = vi.fn(); + +const mockTemplatesResponse = { + '/workspace-templates': { + templates: [ + { id: '1', name: 'template1', title: 'Template 1', description: 'd1', version: '1.0', resourceType: 'workspace', current: true, properties: {}, customActions: [], required: [], uiSchema: {} }, + { id: '2', name: 'template1', title: 'Template 1', description: 'd1', version: '2.0', resourceType: 'workspace', current: false, properties: {}, customActions: [], required: [], uiSchema: {} }, + ] as unknown as ResourceTemplate[], + }, + '/shared-service-templates': { + templates: [ + { id: '3', name: 'template2', title: 'Template 2', description: 'd2', version: '1.0', resourceType: 'shared-service', current: true, properties: {}, customActions: [], required: [], uiSchema: {} }, + ] as unknown as ResourceTemplate[], + }, + '/workspace-service-templates': { + templates: [ + { id: '4', name: 'template3', title: 'Template 3', description: 'd3', version: '1.0', resourceType: 'workspace-service', current: true, properties: {}, customActions: [], required: [], uiSchema: {} }, + ] as unknown as ResourceTemplate[], + }, + '/workspace-service-templates/template3/user-resource-templates': { + templates: [ + { id: '5', name: 'template4', title: 'Template 4', description: 'd4', version: '1.0', resourceType: 'user-resource', current: true, properties: {}, customActions: [], required: [], uiSchema: {}, parentVm: 'template3' }, + ] as unknown as ResourceTemplate[], + }, +}; + +describe('Templates Component', () => { + beforeEach(() => { + vi.spyOn(useAuthApiCall, 'useAuthApiCall').mockReturnValue(mockApiCall); + mockApiCall.mockImplementation(async (url: string, method: string) => { + if (method === 'DELETE') { + return Promise.resolve(); + } + return Promise.resolve(mockTemplatesResponse[url] || { templates: [] }); + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('renders templates and handles deletion', async () => { + render( {}} />); + expect(screen.getByText('Registered Templates')).toBeInTheDocument(); + + // Wait for all templates to be rendered + expect(await screen.findByText('template1')).toBeInTheDocument(); + expect(await screen.findByText('template2')).toBeInTheDocument(); + expect(await screen.findByText('template3')).toBeInTheDocument(); + expect(await screen.findByText('template4')).toBeInTheDocument(); + + // There are two "template1" texts, one for each version + expect(screen.getAllByText('template1').length).toBe(2); + + // Delete a single version of template1 + const deleteButtons = await screen.findAllByText('Delete Version'); + fireEvent.click(deleteButtons[0]); + + // Confirm the deletion + const confirmButton = await screen.findByText('Delete'); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockApiCall).toHaveBeenCalledWith('/workspace-templates/template1?version=1.0', 'DELETE'); + }); + + // The deleted version should be removed from the UI + await waitFor(() => { + expect(screen.getAllByText('template1').length).toBe(1); + }); + + // Delete all versions of template2 + const deleteAllButtons = await screen.findAllByText('Delete All'); + fireEvent.click(deleteAllButtons[1]); // Corresponds to template2 + + // Confirm the deletion + const confirmButton2 = await screen.findByText('Delete'); + fireEvent.click(confirmButton2); + + await waitFor(() => { + expect(mockApiCall).toHaveBeenCalledWith('/shared-service-templates/template2', 'DELETE'); + }); + + await waitFor(() => { + expect(screen.queryByText('template2')).not.toBeInTheDocument(); + }); + }); + + test('handles user resource deletion', async () => { + render( {}} />); + expect(await screen.findByText('template4')).toBeInTheDocument(); + + const deleteButtons = await screen.findAllByText('Delete Version'); + fireEvent.click(deleteButtons[3]); // Corresponds to template4 + + const confirmButton = await screen.findByText('Delete'); + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(mockApiCall).toHaveBeenCalledWith('/workspace-service-templates/template3/user-resource-templates/template4?version=1.0', 'DELETE'); + }); + }); +}); diff --git a/ui/app/src/components/shared/admin/Templates.tsx b/ui/app/src/components/shared/admin/Templates.tsx new file mode 100644 index 0000000000..0229c36b5b --- /dev/null +++ b/ui/app/src/components/shared/admin/Templates.tsx @@ -0,0 +1,212 @@ +import React, { useEffect, useState } from 'react'; +import { DetailsList, DetailsListLayoutMode, SelectionMode, IColumn, CommandBar, ICommandBarItemProps, Text, PrimaryButton, DefaultButton, Dialog, DialogFooter, DialogType } from '@fluentui/react'; +import { HttpMethod, useAuthApiCall } from '../../../hooks/useAuthApiCall'; +import { ApiEndpoint } from '../../../models/apiEndpoints'; +import { ResourceTemplate } from '../../../models/resourceTemplate'; + +type TemplateGroup = { + name: string; + versions: ResourceTemplate[]; + resourceType: string; + parent?: string; +}; + +interface TemplatesProps { + onClose: () => void; +} + +const Templates: React.FC = ({ onClose }) => { + const [templateGroups, setTemplateGroups] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [dialogHidden, setDialogHidden] = useState(true); + const [deleteAction, setDeleteAction] = useState<{ action: () => void } | null>(null); + + const apiCall = useAuthApiCall(); + + const fetchTemplates = async () => { + setIsLoading(true); + try { + const [workspaceTemplates, sharedServiceTemplates, workspaceServiceTemplates] = await Promise.all([ + apiCall(ApiEndpoint.WorkspaceTemplates, HttpMethod.Get), + apiCall(ApiEndpoint.SharedServiceTemplates, HttpMethod.Get), + apiCall(ApiEndpoint.WorkspaceServiceTemplates, HttpMethod.Get) + ]); + + const allTemplates: (ResourceTemplate & { parent?: string })[] = [ + ...(workspaceTemplates.templates || []), + ...(sharedServiceTemplates.templates || []), + ...(workspaceServiceTemplates.templates || []) + ]; + + // Fetch user resource templates for each workspace service template + if (workspaceServiceTemplates.templates) { + const userResourcePromises = workspaceServiceTemplates.templates.map(async template => { + const result = await apiCall(`${ApiEndpoint.WorkspaceServiceTemplates}/${template.name}/${ApiEndpoint.UserResourceTemplates}`, HttpMethod.Get); + if (result.templates) { + return result.templates.map(t => ({ ...t, parent: template.name })); + } + return []; + }); + const userResourceResults = await Promise.all(userResourcePromises); + userResourceResults.forEach(result => { + allTemplates.push(...result); + }); + } + + const grouped = allTemplates.reduce((acc, template) => { + const group = acc.find(g => g.name === template.name && g.resourceType === template.resourceType); + if (group) { + group.versions.push(template); + } else { + acc.push({ name: template.name, versions: [template], resourceType: template.resourceType, parent: template.parent }); + } + return acc; + }, [] as TemplateGroup[]); + + setTemplateGroups(grouped); + } catch (error) { + console.error('Failed to fetch templates', error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchTemplates(); + }, [apiCall]); + + const showDeleteConfirmation = (action: () => void) => { + setDeleteAction({ action }); + setDialogHidden(false); + }; + + const performDelete = () => { + if (deleteAction) { + deleteAction.action(); + } + setDialogHidden(true); + setDeleteAction(null); + }; + + const handleDeleteVersion = (name: string, version: string, resourceType: string, parent?: string) => { + showDeleteConfirmation(async () => { + let url = ''; + switch (resourceType) { + case 'workspace': + url = `${ApiEndpoint.WorkspaceTemplates}/${name}?version=${version}`; + break; + case 'shared-service': + url = `${ApiEndpoint.SharedServiceTemplates}/${name}?version=${version}`; + break; + case 'workspace-service': + url = `${ApiEndpoint.WorkspaceServiceTemplates}/${name}?version=${version}`; + break; + case 'user-resource': + if (parent) { + url = `${ApiEndpoint.WorkspaceServiceTemplates}/${parent}/${ApiEndpoint.UserResourceTemplates}/${name}?version=${version}`; + } + break; + } + + if (url) { + await apiCall(url, HttpMethod.Delete); + fetchTemplates(); + } else { + console.error('Could not determine delete url'); + } + }); + }; + + const handleDeleteAll = (name: string, resourceType: string, parent?: string) => { + showDeleteConfirmation(async () => { + let url = ''; + switch (resourceType) { + case 'workspace': + url = `${ApiEndpoint.WorkspaceTemplates}/${name}`; + break; + case 'shared-service': + url = `${ApiEndpoint.SharedServiceTemplates}/${name}`; + break; + case 'workspace-service': + url = `${ApiEndpoint.WorkspaceServiceTemplates}/${name}`; + break; + case 'user-resource': + if (parent) { + url = `${ApiEndpoint.WorkspaceServiceTemplates}/${parent}/${ApiEndpoint.UserResourceTemplates}/${name}`; + } + break; + } + + if (url) { + await apiCall(url, HttpMethod.Delete); + fetchTemplates(); + } else { + console.error('Could not determine delete url'); + } + }); + }; + + const columns: IColumn[] = [ + { key: 'name', name: 'Name', fieldName: 'name', minWidth: 150, isResizable: true }, + { key: 'version', name: 'Version', fieldName: 'version', minWidth: 100, isResizable: true }, + { key: 'resourceType', name: 'Type', fieldName: 'resourceType', minWidth: 100, isResizable: true }, + { key: 'current', name: 'Current', fieldName: 'current', minWidth: 50, onRender: (item: ResourceTemplate) => (item.current ? 'Yes' : 'No') }, + { + key: 'actions', name: 'Actions', minWidth: 200, onRender: (item: ResourceTemplate & { parent?: string }) => ( + <> + handleDeleteVersion(item.name, item.version, item.resourceType, item.parent)} styles={{ root: { marginRight: 8 } }} /> + + ) + } + ]; + + const commandBarItems: ICommandBarItemProps[] = [ + { + key: 'close', + text: 'Close', + iconProps: { iconName: 'Cancel' }, + onClick: onClose, + }, + ]; + + return ( + <> + + Registered Templates + {templateGroups.map(group => ( +
+
+ {group.name} ({group.resourceType}) + handleDeleteAll(group.name, group.resourceType, group.parent)} /> +
+ +
+ ))} + + + ); +}; + +export default Templates;