feat(ws): use workspace counts from API response (#508)

Signed-off-by: Guilherme Caponetto <638737+caponetto@users.noreply.github.com>
This commit is contained in:
Guilherme Caponetto 2025-07-29 16:20:47 -03:00 committed by Bhakti Narvekar
parent 7a6bb30e76
commit 77c69c5aa3
7 changed files with 461 additions and 32 deletions

View File

@ -38,7 +38,7 @@ export const NamespaceContextProvider: React.FC<NamespaceContextProviderProps> =
const namespaceNames = namespacesData.map((ns) => ns.name);
setNamespaces(namespaceNames);
setSelectedNamespace(lastUsedNamespace.length ? lastUsedNamespace : namespaceNames[0]);
if (!lastUsedNamespace.length) {
if (!lastUsedNamespace.length || !namespaceNames.includes(lastUsedNamespace)) {
setLastUsedNamespace(storageKey, namespaceNames[0]);
}
} else {

View File

@ -0,0 +1,341 @@
import { waitFor } from '@testing-library/react';
import { renderHook } from '~/__tests__/unit/testUtils/hooks';
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
import { useWorkspaceCountPerKind } from '~/app/hooks/useWorkspaceCountPerKind';
import {
Workspace,
WorkspaceImageConfigValue,
WorkspaceKind,
WorkspaceKindInfo,
WorkspacePodConfigValue,
} from '~/shared/api/backendApiTypes';
import { NotebookAPIs } from '~/shared/api/notebookApi';
import { buildMockWorkspace, buildMockWorkspaceKind } from '~/shared/mock/mockBuilder';
jest.mock('~/app/hooks/useNotebookAPI', () => ({
useNotebookAPI: jest.fn(),
}));
const mockUseNotebookAPI = useNotebookAPI as jest.MockedFunction<typeof useNotebookAPI>;
const baseWorkspaceKindInfoTest: WorkspaceKindInfo = {
name: 'jupyter',
missing: false,
icon: { url: '' },
logo: { url: '' },
};
const baseWorkspaceTest = buildMockWorkspace({
name: 'workspace',
namespace: 'namespace',
workspaceKind: baseWorkspaceKindInfoTest,
});
const baseImageConfigTest: WorkspaceImageConfigValue = {
id: 'image',
displayName: 'Image',
description: 'Test image',
labels: [],
hidden: false,
clusterMetrics: undefined,
};
const basePodConfigTest: WorkspacePodConfigValue = {
id: 'podConfig',
displayName: 'Pod Config',
description: 'Test pod config',
labels: [],
hidden: false,
clusterMetrics: undefined,
};
describe('useWorkspaceCountPerKind', () => {
const mockListAllWorkspaces = jest.fn();
const mockListWorkspaceKinds = jest.fn();
const mockApi: Partial<NotebookAPIs> = {
listAllWorkspaces: mockListAllWorkspaces,
listWorkspaceKinds: mockListWorkspaceKinds,
};
beforeEach(() => {
jest.clearAllMocks();
mockUseNotebookAPI.mockReturnValue({
api: mockApi as NotebookAPIs,
apiAvailable: true,
refreshAllAPI: jest.fn(),
});
});
it('should return empty object initially', () => {
mockListAllWorkspaces.mockResolvedValue([]);
mockListWorkspaceKinds.mockResolvedValue([]);
const { result } = renderHook(() => useWorkspaceCountPerKind());
waitFor(() => {
expect(result.current).toEqual({});
});
});
it('should fetch and calculate workspace counts on mount', async () => {
const mockWorkspaces: Workspace[] = [
{
...baseWorkspaceTest,
name: 'workspace1',
namespace: 'namespace1',
workspaceKind: { ...baseWorkspaceKindInfoTest, name: 'jupyter1' },
},
{
...baseWorkspaceTest,
name: 'workspace2',
namespace: 'namespace1',
workspaceKind: { ...baseWorkspaceKindInfoTest, name: 'jupyter1' },
},
{
...baseWorkspaceTest,
name: 'workspace3',
namespace: 'namespace2',
workspaceKind: { ...baseWorkspaceKindInfoTest, name: 'jupyter2' },
},
];
const mockWorkspaceKinds: WorkspaceKind[] = [
buildMockWorkspaceKind({
name: 'jupyter1',
clusterMetrics: { workspacesCount: 10 },
podTemplate: {
podMetadata: { labels: {}, annotations: {} },
volumeMounts: { home: '/home' },
options: {
imageConfig: {
default: 'image1',
values: [
{
...baseImageConfigTest,
id: 'image1',
clusterMetrics: { workspacesCount: 1 },
},
{
...baseImageConfigTest,
id: 'image2',
clusterMetrics: { workspacesCount: 2 },
},
],
},
podConfig: {
default: 'podConfig1',
values: [
{
...basePodConfigTest,
id: 'podConfig1',
clusterMetrics: { workspacesCount: 3 },
},
{
...basePodConfigTest,
id: 'podConfig2',
clusterMetrics: { workspacesCount: 4 },
},
],
},
},
},
}),
buildMockWorkspaceKind({
name: 'jupyter2',
clusterMetrics: { workspacesCount: 20 },
podTemplate: {
podMetadata: { labels: {}, annotations: {} },
volumeMounts: { home: '/home' },
options: {
imageConfig: {
default: 'image1',
values: [
{
...baseImageConfigTest,
id: 'image1',
clusterMetrics: { workspacesCount: 11 },
},
],
},
podConfig: {
default: 'podConfig1',
values: [
{
...basePodConfigTest,
id: 'podConfig1',
clusterMetrics: { workspacesCount: 12 },
},
],
},
},
},
}),
];
mockListAllWorkspaces.mockResolvedValue(mockWorkspaces);
mockListWorkspaceKinds.mockResolvedValue(mockWorkspaceKinds);
const { result } = renderHook(() => useWorkspaceCountPerKind());
await waitFor(() => {
expect(result.current).toEqual({
jupyter1: {
count: 10,
countByImage: {
image1: 1,
image2: 2,
},
countByPodConfig: {
podConfig1: 3,
podConfig2: 4,
},
countByNamespace: {
namespace1: 2,
},
},
jupyter2: {
count: 20,
countByImage: {
image1: 11,
},
countByPodConfig: {
podConfig1: 12,
},
countByNamespace: {
namespace2: 1,
},
},
});
});
});
it('should handle missing cluster metrics gracefully', async () => {
const mockEmptyWorkspaces: Workspace[] = [];
const mockWorkspaceKinds: WorkspaceKind[] = [
buildMockWorkspaceKind({
name: 'no-metrics',
clusterMetrics: undefined,
podTemplate: {
podMetadata: { labels: {}, annotations: {} },
volumeMounts: { home: '/home' },
options: {
imageConfig: {
default: baseImageConfigTest.id,
values: [{ ...baseImageConfigTest }],
},
podConfig: {
default: basePodConfigTest.id,
values: [{ ...basePodConfigTest }],
},
},
},
}),
buildMockWorkspaceKind({
name: 'no-metrics-2',
clusterMetrics: undefined,
podTemplate: {
podMetadata: { labels: {}, annotations: {} },
volumeMounts: { home: '/home' },
options: {
imageConfig: {
default: 'empty',
values: [],
},
podConfig: {
default: 'empty',
values: [],
},
},
},
}),
];
mockListAllWorkspaces.mockResolvedValue(mockEmptyWorkspaces);
mockListWorkspaceKinds.mockResolvedValue(mockWorkspaceKinds);
const { result } = renderHook(() => useWorkspaceCountPerKind());
await waitFor(() => {
expect(result.current).toEqual({
'no-metrics': {
count: 0,
countByImage: {
image: 0,
},
countByPodConfig: {
podConfig: 0,
},
countByNamespace: {},
},
'no-metrics-2': {
count: 0,
countByImage: {},
countByPodConfig: {},
countByNamespace: {},
},
});
});
});
it('should return empty object in case of API errors rather than propagating them', async () => {
mockListAllWorkspaces.mockRejectedValue(new Error('API Error'));
mockListWorkspaceKinds.mockRejectedValue(new Error('API Error'));
const { result } = renderHook(() => useWorkspaceCountPerKind());
await waitFor(() => {
expect(result.current).toEqual({});
});
});
it('should handle empty workspace kinds array', async () => {
mockListWorkspaceKinds.mockResolvedValue([]);
const { result } = renderHook(() => useWorkspaceCountPerKind());
await waitFor(() => {
expect(result.current).toEqual({});
});
});
it('should handle workspaces with no matching kinds', async () => {
const mockWorkspaces: Workspace[] = [baseWorkspaceTest];
const workspaceKind = buildMockWorkspaceKind({
name: 'nomatch',
clusterMetrics: { workspacesCount: 0 },
podTemplate: {
podMetadata: { labels: {}, annotations: {} },
volumeMounts: { home: '/home' },
options: {
imageConfig: {
default: baseImageConfigTest.id,
values: [{ ...baseImageConfigTest }],
},
podConfig: {
default: basePodConfigTest.id,
values: [{ ...basePodConfigTest }],
},
},
},
});
const mockWorkspaceKinds: WorkspaceKind[] = [workspaceKind];
mockListAllWorkspaces.mockResolvedValue(mockWorkspaces);
mockListWorkspaceKinds.mockResolvedValue(mockWorkspaceKinds);
const { result } = renderHook(() => useWorkspaceCountPerKind());
await waitFor(() => {
expect(result.current).toEqual({
[workspaceKind.name]: {
count: 0,
countByImage: { [baseImageConfigTest.id]: 0 },
countByPodConfig: { [basePodConfigTest.id]: 0 },
countByNamespace: {},
},
});
});
});
});

View File

@ -2,45 +2,96 @@ import { useEffect, useState } from 'react';
import { useNotebookAPI } from '~/app/hooks/useNotebookAPI';
import { Workspace, WorkspaceKind } from '~/shared/api/backendApiTypes';
import { WorkspaceCountPerOption } from '~/app/types';
import { NotebookAPIs } from '~/shared/api/notebookApi';
export type WorkspaceCountPerKind = Record<WorkspaceKind['name'], WorkspaceCountPerOption>;
// TODO: This hook is temporary; we should get counts from the API directly
export const useWorkspaceCountPerKind = (): WorkspaceCountPerKind => {
const { api } = useNotebookAPI();
const [workspaceCountPerKind, setWorkspaceCountPerKind] = useState<WorkspaceCountPerKind>({});
useEffect(() => {
api.listAllWorkspaces({}).then((workspaces) => {
const countPerKind = workspaces.reduce((acc: WorkspaceCountPerKind, workspace: Workspace) => {
acc[workspace.workspaceKind.name] = acc[workspace.workspaceKind.name] ?? {
count: 0,
countByImage: {},
countByPodConfig: {},
countByNamespace: {},
};
acc[workspace.workspaceKind.name].count =
(acc[workspace.workspaceKind.name].count || 0) + 1;
acc[workspace.workspaceKind.name].countByImage[
workspace.podTemplate.options.imageConfig.current.id
] =
(acc[workspace.workspaceKind.name].countByImage[
workspace.podTemplate.options.imageConfig.current.id
] || 0) + 1;
acc[workspace.workspaceKind.name].countByPodConfig[
workspace.podTemplate.options.podConfig.current.id
] =
(acc[workspace.workspaceKind.name].countByPodConfig[
workspace.podTemplate.options.podConfig.current.id
] || 0) + 1;
acc[workspace.workspaceKind.name].countByNamespace[workspace.namespace] =
(acc[workspace.workspaceKind.name].countByNamespace[workspace.namespace] || 0) + 1;
return acc;
}, {});
setWorkspaceCountPerKind(countPerKind);
});
const fetchAndSetCounts = async () => {
try {
const countPerKind = await loadWorkspaceCounts(api);
setWorkspaceCountPerKind(countPerKind);
} catch (err) {
// TODO: alert user about error
console.error('Failed to fetch workspace counts:', err);
}
};
fetchAndSetCounts();
}, [api]);
return workspaceCountPerKind;
};
async function loadWorkspaceCounts(api: NotebookAPIs): Promise<WorkspaceCountPerKind> {
const [workspaces, workspaceKinds] = await Promise.all([
api.listAllWorkspaces({}),
api.listWorkspaceKinds({}),
]);
return extractCountPerKind({ workspaceKinds, workspaces });
}
function extractCountByNamespace(args: {
kind: WorkspaceKind;
workspaces: Workspace[];
}): WorkspaceCountPerOption['countByNamespace'] {
const { kind, workspaces } = args;
return workspaces.reduce<WorkspaceCountPerOption['countByNamespace']>(
(acc, { namespace, workspaceKind }) => {
if (kind.name === workspaceKind.name) {
acc[namespace] = (acc[namespace] ?? 0) + 1;
}
return acc;
},
{},
);
}
function extractCountByImage(
workspaceKind: WorkspaceKind,
): WorkspaceCountPerOption['countByImage'] {
return workspaceKind.podTemplate.options.imageConfig.values.reduce<
WorkspaceCountPerOption['countByImage']
>((acc, { id, clusterMetrics }) => {
acc[id] = clusterMetrics?.workspacesCount ?? 0;
return acc;
}, {});
}
function extractCountByPodConfig(
workspaceKind: WorkspaceKind,
): WorkspaceCountPerOption['countByPodConfig'] {
return workspaceKind.podTemplate.options.podConfig.values.reduce<
WorkspaceCountPerOption['countByPodConfig']
>((acc, { id, clusterMetrics }) => {
acc[id] = clusterMetrics?.workspacesCount ?? 0;
return acc;
}, {});
}
function extractTotalCount(workspaceKind: WorkspaceKind): number {
return workspaceKind.clusterMetrics?.workspacesCount ?? 0;
}
function extractCountPerKind(args: {
workspaceKinds: WorkspaceKind[];
workspaces: Workspace[];
}): WorkspaceCountPerKind {
const { workspaceKinds, workspaces } = args;
return workspaceKinds.reduce<WorkspaceCountPerKind>((acc, kind) => {
acc[kind.name] = {
count: extractTotalCount(kind),
countByImage: extractCountByImage(kind),
countByPodConfig: extractCountByPodConfig(kind),
countByNamespace: extractCountByNamespace({ kind, workspaces }),
};
return acc;
}, {});
}

View File

@ -63,7 +63,7 @@ export const WorkspaceKindDetailsTable: React.FC<WorkspaceKindDetailsTableProps>
</Tr>
</Thead>
<Tbody>
{rowPages[page - 1].map((row) => (
{rowPages[page - 1]?.map((row) => (
<Tr key={row.id}>
<Td>{row.displayName}</Td>
<Td>

View File

@ -27,6 +27,7 @@ export interface WorkspacePodConfigValue {
labels: WorkspaceOptionLabel[];
hidden: boolean;
redirect?: WorkspaceOptionRedirect;
clusterMetrics?: WorkspaceKindClusterMetrics;
}
export interface WorkspaceKindPodConfig {
@ -71,6 +72,7 @@ export interface WorkspaceImageConfigValue {
labels: WorkspaceOptionLabel[];
hidden: boolean;
redirect?: WorkspaceOptionRedirect;
clusterMetrics?: WorkspaceKindClusterMetrics;
}
export interface WorkspaceKindImageConfig {
@ -107,9 +109,14 @@ export interface WorkspaceKind {
hidden: boolean;
icon: WorkspaceImageRef;
logo: WorkspaceImageRef;
clusterMetrics?: WorkspaceKindClusterMetrics;
podTemplate: WorkspaceKindPodTemplate;
}
export interface WorkspaceKindClusterMetrics {
workspacesCount: number;
}
export enum WorkspaceState {
WorkspaceStateRunning = 'Running',
WorkspaceStateTerminating = 'Terminating',

View File

@ -147,6 +147,9 @@ export const buildMockWorkspaceKind = (workspaceKind?: Partial<WorkspaceKind>):
logo: {
url: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg',
},
clusterMetrics: {
workspacesCount: 10,
},
podTemplate: {
podMetadata: {
labels: {
@ -172,6 +175,9 @@ export const buildMockWorkspaceKind = (workspaceKind?: Partial<WorkspaceKind>):
{ key: 'jupyterlabVersion', value: '1.8.0' },
],
hidden: true,
clusterMetrics: {
workspacesCount: 0,
},
redirect: {
to: 'jupyterlab_scipy_190',
message: {
@ -196,6 +202,9 @@ export const buildMockWorkspaceKind = (workspaceKind?: Partial<WorkspaceKind>):
level: WorkspaceRedirectMessageLevel.RedirectMessageLevelWarning,
},
},
clusterMetrics: {
workspacesCount: 1,
},
},
{
id: 'jupyterlab_scipy_200',
@ -213,6 +222,9 @@ export const buildMockWorkspaceKind = (workspaceKind?: Partial<WorkspaceKind>):
level: WorkspaceRedirectMessageLevel.RedirectMessageLevelWarning,
},
},
clusterMetrics: {
workspacesCount: 2,
},
},
{
id: 'jupyterlab_scipy_210',
@ -230,6 +242,9 @@ export const buildMockWorkspaceKind = (workspaceKind?: Partial<WorkspaceKind>):
level: WorkspaceRedirectMessageLevel.RedirectMessageLevelWarning,
},
},
clusterMetrics: {
workspacesCount: 3,
},
},
],
},
@ -252,6 +267,9 @@ export const buildMockWorkspaceKind = (workspaceKind?: Partial<WorkspaceKind>):
level: WorkspaceRedirectMessageLevel.RedirectMessageLevelDanger,
},
},
clusterMetrics: {
workspacesCount: 0,
},
},
{
id: 'large_cpu',
@ -270,6 +288,9 @@ export const buildMockWorkspaceKind = (workspaceKind?: Partial<WorkspaceKind>):
level: WorkspaceRedirectMessageLevel.RedirectMessageLevelDanger,
},
},
clusterMetrics: {
workspacesCount: 5,
},
},
],
},

View File

@ -27,16 +27,25 @@ export const mockNamespaces = [mockNamespace1, mockNamespace2, mockNamespace3];
export const mockWorkspaceKind1: WorkspaceKind = buildMockWorkspaceKind({
name: 'jupyterlab1',
displayName: 'JupyterLab Notebook 1',
clusterMetrics: {
workspacesCount: 18,
},
});
export const mockWorkspaceKind2: WorkspaceKind = buildMockWorkspaceKind({
name: 'jupyterlab2',
displayName: 'JupyterLab Notebook 2',
clusterMetrics: {
workspacesCount: 2,
},
});
export const mockWorkspaceKind3: WorkspaceKind = buildMockWorkspaceKind({
name: 'jupyterlab3',
displayName: 'JupyterLab Notebook 3',
clusterMetrics: {
workspacesCount: 0,
},
});
export const mockWorkspaceKinds = [mockWorkspaceKind1, mockWorkspaceKind2, mockWorkspaceKind3];