Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ui/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>);
Expand Down Expand Up @@ -124,6 +125,7 @@ export const App: React.FunctionComponent = () => {
>
<Routes>
<Route path="*" element={<RootLayout />} />
<Route path="/admin" element={<Admin />} />
<Route
path="/workspaces/:workspaceId//*"
element={
Expand Down Expand Up @@ -191,5 +193,3 @@ const stackStyles: IStackStyles = {
height: "100vh",
},
};

export { default as Admin } from "./components/shared/admin/Admin";
6 changes: 5 additions & 1 deletion ui/app/src/components/shared/admin/Admin.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
import React, { useState } from 'react';
import { Stack, DefaultButton } from '@fluentui/react';
import Operations from './Operations';
import Templates from './Templates';

const Admin: React.FC = () => {
const [showOperations, setShowOperations] = useState(false);
const [showTemplates, setShowTemplates] = useState(false);

return (
<Stack className="tre-panel">
<h1>Admin</h1>
<p style={{ color: 'Orange' }}>Warning: These admin functions are advanced and experimental, proceed with caution.</p>

{!showOperations && (
{!showOperations && !showTemplates && (
<Stack horizontal tokens={{ childrenGap: 12 }} styles={{ root: { marginTop: 10 } }}>
{/* Future admin actions should be added here as buttons */}
<DefaultButton text="Operations" onClick={() => setShowOperations(true)} />
<DefaultButton text="Templates" onClick={() => setShowTemplates(true)} />
{/* Example placeholder for future admin functions */}
<DefaultButton text="(coming soon) Other admin action" disabled />
</Stack>
)}

{showOperations && <Operations onClose={() => setShowOperations(false)} />}
{showTemplates && <Templates onClose={() => setShowTemplates(false)} />}
</Stack>
);
};
Expand Down
113 changes: 113 additions & 0 deletions ui/app/src/components/shared/admin/Templates.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Templates onClose={() => {}} />);
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(<Templates onClose={() => {}} />);
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');
});
});
});
212 changes: 212 additions & 0 deletions ui/app/src/components/shared/admin/Templates.tsx
Original file line number Diff line number Diff line change
@@ -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<TemplatesProps> = ({ onClose }) => {
const [templateGroups, setTemplateGroups] = useState<TemplateGroup[]>([]);
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 }) => (
<>
<PrimaryButton text="Delete Version" onClick={() => 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 (
<>
<CommandBar items={commandBarItems} />
<Text variant="xxLarge" style={{ padding: '20px' }}>Registered Templates</Text>
{templateGroups.map(group => (
<div key={`${group.name}-${group.resourceType}`} style={{ padding: '20px' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '10px' }}>
<Text variant="large" style={{ marginRight: '20px' }}>{group.name} ({group.resourceType})</Text>
<DefaultButton text="Delete All" onClick={() => handleDeleteAll(group.name, group.resourceType, group.parent)} />
</div>
<DetailsList
items={group.versions}
columns={columns}
layoutMode={DetailsListLayoutMode.justified}
selectionMode={SelectionMode.none}
loading={isLoading}
/>
</div>
))}
<Dialog
hidden={dialogHidden}
onDismiss={() => setDialogHidden(true)}
dialogContentProps={{
type: DialogType.normal,
title: 'Confirm Deletion',
subText: 'Are you sure you want to delete this template? This action cannot be undone.',
}}
modalProps={{
isBlocking: true,
}}
>
<DialogFooter>
<PrimaryButton onClick={performDelete} text="Delete" />
<DefaultButton onClick={() => setDialogHidden(true)} text="Cancel" />
</DialogFooter>
</Dialog>
</>
);
};

export default Templates;