Skip to content

Commit b5cf206

Browse files
authored
Add subscription list page (opendatahub-io#6789)
* add subscription list page * pr comments and added cypress tests * fix data-test-id
1 parent bdac8ed commit b5cf206

File tree

16 files changed

+657
-4
lines changed

16 files changed

+657
-4
lines changed

packages/cypress/cypress/pages/modelsAsAService.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,74 @@ class CopyApiKeyModal extends Modal {
540540
}
541541
}
542542

543+
class SubscriptionsPage {
544+
visit(): void {
545+
cy.visitWithLogin('/maas/subscriptions');
546+
this.wait();
547+
}
548+
549+
private wait(): void {
550+
cy.findByTestId('app-page-title').should('exist');
551+
cy.testA11y();
552+
}
553+
554+
findTable(): Cypress.Chainable<JQuery<HTMLElement>> {
555+
return cy.findByTestId('subscriptions-table');
556+
}
557+
558+
findRows(): Cypress.Chainable<JQuery<HTMLElement>> {
559+
return this.findTable().find('tbody tr');
560+
}
561+
562+
findActionsToggle(): Cypress.Chainable<JQuery<HTMLElement>> {
563+
return cy.findByTestId('subscription-actions');
564+
}
565+
566+
getRow(name: string): SubscriptionTableRow {
567+
return new SubscriptionTableRow(() =>
568+
this.findTable().find('tbody tr').contains('td', name).parents('tr'),
569+
);
570+
}
571+
572+
findTitle(): Cypress.Chainable<JQuery<HTMLElement>> {
573+
return cy.findByTestId('app-page-title');
574+
}
575+
576+
findDescription(): Cypress.Chainable<JQuery<HTMLElement>> {
577+
return cy.findByTestId('app-page-description');
578+
}
579+
580+
findFilterInput(): Cypress.Chainable<JQuery<HTMLElement>> {
581+
return cy.findByTestId('subscriptions-filter-input');
582+
}
583+
584+
findFilterResetButton(): Cypress.Chainable<JQuery<HTMLElement>> {
585+
return cy.findByRole('button', { name: 'Clear all filters' });
586+
}
587+
588+
findCreateSubscriptionButton(): Cypress.Chainable<JQuery<HTMLElement>> {
589+
return cy.findByTestId('create-subscription-button');
590+
}
591+
592+
findEmptyState(): Cypress.Chainable<JQuery<HTMLElement>> {
593+
return cy.findByTestId('empty-state-title');
594+
}
595+
}
596+
597+
class SubscriptionTableRow extends TableRow {
598+
findName(): Cypress.Chainable<JQuery<HTMLElement>> {
599+
return this.find().find('[data-label="Name"]');
600+
}
601+
602+
findGroups(): Cypress.Chainable<JQuery<HTMLElement>> {
603+
return this.find().find('[data-label="Groups"]');
604+
}
605+
606+
findModels(): Cypress.Chainable<JQuery<HTMLElement>> {
607+
return this.find().find('[data-label="Models"]');
608+
}
609+
}
610+
543611
export const tiersPage = new TiersPage();
544612
export const createTierPage = new CreateTierPage();
545613
export const deleteTierModal = new DeleteTierModal();
@@ -550,3 +618,4 @@ export const bulkRevokeAPIKeyModal = new BulkRevokeAPIKeyModal();
550618
export const revokeAPIKeyModal = new RevokeAPIKeyModal();
551619
export const createApiKeyModal = new CreateApiKeyModal();
552620
export const copyApiKeyModal = new CopyApiKeyModal();
621+
export const subscriptionsPage = new SubscriptionsPage();

packages/cypress/cypress/support/commands/odh.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ import type {
9797
BulkRevokeResponse,
9898
CreateAPIKeyResponse,
9999
} from '@odh-dashboard/maas/types/api-key';
100+
import type { MaaSSubscription } from '@odh-dashboard/maas/types/subscriptions';
100101

101102
type SuccessErrorResponse = {
102103
success: boolean;
@@ -1142,6 +1143,10 @@ declare global {
11421143
((
11431144
type: 'GET /maas/api/v1/namespaces',
11441145
response: OdhResponse<{ data: { metadata: { name: string } }[] }>,
1146+
) => Cypress.Chainable<null>) &
1147+
((
1148+
type: 'GET /maas/api/v1/all-subscriptions',
1149+
response: OdhResponse<{ data: MaaSSubscription[] }>,
11451150
) => Cypress.Chainable<null>);
11461151
}
11471152
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { mockDashboardConfig, mockDscStatus } from '@odh-dashboard/internal/__mocks__';
2+
import { DataScienceStackComponent } from '@odh-dashboard/internal/concepts/areas/types';
3+
import { asProductAdminUser } from '../../../utils/mockUsers';
4+
import { subscriptionsPage } from '../../../pages/modelsAsAService';
5+
import { mockSubscriptions } from '../../../utils/maasUtils';
6+
7+
describe('Subscriptions Page', () => {
8+
beforeEach(() => {
9+
asProductAdminUser();
10+
cy.interceptOdh(
11+
'GET /api/config',
12+
mockDashboardConfig({
13+
modelAsService: true,
14+
}),
15+
);
16+
cy.interceptOdh('GET /maas/api/v1/user', {
17+
data: { userId: 'test-user', clusterAdmin: false },
18+
});
19+
cy.interceptOdh('GET /maas/api/v1/namespaces', { data: [] });
20+
cy.interceptOdh(
21+
'GET /api/dsc/status',
22+
mockDscStatus({
23+
components: {
24+
[DataScienceStackComponent.LLAMA_STACK_OPERATOR]: { managementState: 'Managed' },
25+
},
26+
}),
27+
);
28+
cy.interceptOdh('GET /maas/api/v1/all-subscriptions', {
29+
data: mockSubscriptions(),
30+
});
31+
subscriptionsPage.visit();
32+
});
33+
34+
it('should show the empty state when there are no subscriptions', () => {
35+
cy.interceptOdh('GET /maas/api/v1/all-subscriptions', {
36+
data: [],
37+
});
38+
subscriptionsPage.visit();
39+
subscriptionsPage.findEmptyState().should('exist');
40+
subscriptionsPage.findCreateSubscriptionButton().should('exist');
41+
});
42+
43+
it('should display the subscriptions table with correct page content', () => {
44+
subscriptionsPage.findTitle().should('contain.text', 'Subscriptions');
45+
subscriptionsPage
46+
.findDescription()
47+
.should(
48+
'contain.text',
49+
'Subscriptions control access and entitlements to AI model endpoints that are available as a service.',
50+
);
51+
52+
subscriptionsPage.findTable().should('exist');
53+
subscriptionsPage.findRows().should('have.length', 2);
54+
subscriptionsPage.findCreateSubscriptionButton().should('exist');
55+
56+
const premiumRow = subscriptionsPage.getRow('premium-team-sub');
57+
premiumRow.findName().should('contain.text', 'premium-team-sub');
58+
premiumRow.findGroups().should('contain.text', '1 Groups');
59+
premiumRow.findModels().should('contain.text', '2 Models');
60+
61+
const basicRow = subscriptionsPage.getRow('basic-team-sub');
62+
basicRow.findName().should('contain.text', 'basic-team-sub');
63+
basicRow.findGroups().should('contain.text', '1 Groups');
64+
basicRow.findModels().should('contain.text', '1 Models');
65+
66+
subscriptionsPage.findFilterInput().should('exist').type('premium');
67+
subscriptionsPage.findRows().should('have.length', 1);
68+
subscriptionsPage.findFilterResetButton().should('exist').click();
69+
subscriptionsPage.findRows().should('have.length', 2);
70+
71+
premiumRow.findKebabAction('View details').should('exist');
72+
premiumRow.findKebabAction('Edit subscription').should('exist');
73+
premiumRow.findKebabAction('Delete subscription').should('exist');
74+
});
75+
});

packages/cypress/cypress/utils/maasUtils.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
CreateAPIKeyResponse,
55
CreateAPIKeyRequest,
66
} from '@odh-dashboard/maas/types/api-key';
7+
import type { MaaSSubscription } from '@odh-dashboard/maas/types/subscriptions';
78

89
// Standardized tier templates - use these directly or as building blocks
910
export const MOCK_TIERS: Record<'free' | 'premium' | 'enterprise', Tier> = {
@@ -98,6 +99,51 @@ export const mockCreateAPIKeyRequest = (): CreateAPIKeyRequest => {
9899
};
99100
};
100101

102+
export const mockSubscriptions = (): MaaSSubscription[] => [
103+
{
104+
name: 'premium-team-sub',
105+
namespace: 'maas-system',
106+
phase: 'Active',
107+
priority: 10,
108+
owner: {
109+
groups: [{ name: 'premium-users' }],
110+
},
111+
modelRefs: [
112+
{
113+
name: 'granite-3-8b-instruct',
114+
namespace: 'maas-models',
115+
tokenRateLimits: [{ limit: 100000, window: '24h' }],
116+
},
117+
{
118+
name: 'flan-t5-small',
119+
namespace: 'maas-models',
120+
tokenRateLimits: [{ limit: 200000, window: '24h' }],
121+
},
122+
],
123+
tokenMetadata: {
124+
organizationId: 'org-123',
125+
costCenter: 'engineering',
126+
},
127+
creationTimestamp: '2025-03-01T10:00:00Z',
128+
},
129+
{
130+
name: 'basic-team-sub',
131+
namespace: 'maas-system',
132+
phase: 'Active',
133+
owner: {
134+
groups: [{ name: 'system:authenticated' }],
135+
},
136+
modelRefs: [
137+
{
138+
name: 'flan-t5-small',
139+
namespace: 'maas-models',
140+
tokenRateLimits: [{ limit: 10000, window: '24h' }],
141+
},
142+
],
143+
creationTimestamp: '2025-02-15T08:00:00Z',
144+
},
145+
];
146+
101147
export const mockTier = ({
102148
name = 'free',
103149
displayName,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { handleRestFailures, isModArchResponse, APIOptions, restGET } from 'mod-arch-core';
2+
import { BFF_API_VERSION, URL_PREFIX } from '../utilities/const';
3+
import { MaaSSubscription, ModelSubscriptionRef, TokenRateLimit } from '../types/subscriptions';
4+
5+
const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object';
6+
7+
const isMaaSSubscriptionRef = (v: unknown): v is ModelSubscriptionRef =>
8+
isRecord(v) &&
9+
typeof v.name === 'string' &&
10+
typeof v.namespace === 'string' &&
11+
Array.isArray(v.tokenRateLimits) &&
12+
v.tokenRateLimits.every(isTokenRateLimit) &&
13+
(v.tokenRateLimitRef === undefined || typeof v.tokenRateLimitRef === 'string') &&
14+
(v.billingRate === undefined || typeof v.billingRate === 'object');
15+
16+
const isMaaSSubscription = (v: unknown): v is MaaSSubscription =>
17+
isRecord(v) &&
18+
typeof v.name === 'string' &&
19+
typeof v.namespace === 'string' &&
20+
typeof v.phase === 'string' &&
21+
(v.priority === undefined || typeof v.priority === 'number') &&
22+
typeof v.owner === 'object' &&
23+
Array.isArray(v.modelRefs) &&
24+
v.modelRefs.every(isMaaSSubscriptionRef) &&
25+
(v.tokenMetadata === undefined || typeof v.tokenMetadata === 'object') &&
26+
typeof v.creationTimestamp === 'string';
27+
const isTokenRateLimit = (v: unknown): v is TokenRateLimit =>
28+
isRecord(v) && typeof v.limit === 'number' && typeof v.window === 'string';
29+
30+
const isMaaSSubscriptionArray = (v: unknown): v is MaaSSubscription[] =>
31+
Array.isArray(v) && v.every(isMaaSSubscription);
32+
33+
/** GET /api/v1/all-subscriptions - List all subscriptions */
34+
export const listSubscriptions =
35+
(hostPath = '') =>
36+
(opts: APIOptions): Promise<MaaSSubscription[]> =>
37+
handleRestFailures(
38+
restGET(hostPath, `${URL_PREFIX}/api/${BFF_API_VERSION}/all-subscriptions`, {}, opts),
39+
).then((response) => {
40+
if (isModArchResponse<unknown>(response) && isMaaSSubscriptionArray(response.data)) {
41+
return response.data;
42+
}
43+
throw new Error('Invalid response format');
44+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
import {
3+
POLL_INTERVAL,
4+
useFetchState,
5+
type APIOptions,
6+
type FetchState,
7+
type FetchStateCallbackPromise,
8+
} from 'mod-arch-core';
9+
import { listSubscriptions } from '../api/subscriptions';
10+
import { MaaSSubscription } from '../types/subscriptions';
11+
12+
export const useListSubscriptions = (): FetchState<MaaSSubscription[]> => {
13+
const callback = React.useCallback<FetchStateCallbackPromise<MaaSSubscription[]>>(
14+
(opts: APIOptions) => listSubscriptions()(opts),
15+
[],
16+
);
17+
18+
return useFetchState<MaaSSubscription[]>(callback, [], { refreshRate: POLL_INTERVAL });
19+
};
Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,74 @@
11
import * as React from 'react';
22
import ApplicationsPage from '@odh-dashboard/internal/pages/ApplicationsPage';
3+
import { PageSection } from '@patternfly/react-core';
4+
import { useListSubscriptions } from '~/app/hooks/useListSubscriptions';
5+
import { MaaSSubscription } from '~/app/types/subscriptions';
6+
import { SubscriptionsTable } from './allSubscriptions/SubscriptionsTable';
7+
import SubscriptionsToolbar from './allSubscriptions/SubscriptionsToolbar';
8+
import {
9+
initialSubscriptionsFilterData,
10+
SubscriptionsFilterDataType,
11+
SubscriptionsFilterOptions,
12+
} from './allSubscriptions/const';
13+
import DeleteSubscriptionModal from './DeleteSubscriptionModal';
14+
import EmptySubscriptionsPage from './EmptySubscriptionsPage';
315

4-
const AllSubscriptionsPage: React.FC = () => (
5-
<ApplicationsPage title="Subscriptions" empty={false} loaded />
6-
);
16+
const AllSubscriptionsPage: React.FC = () => {
17+
const [subscriptions, loaded, error] = useListSubscriptions();
18+
const [filterData, setFilterData] = React.useState<SubscriptionsFilterDataType>(
19+
initialSubscriptionsFilterData,
20+
);
21+
22+
const onFilterUpdate = React.useCallback(
23+
(key: string, value?: string | { label: string; value: string }) =>
24+
setFilterData((prev) => ({ ...prev, [key]: value })),
25+
[],
26+
);
27+
28+
const onClearFilters = React.useCallback(() => setFilterData(initialSubscriptionsFilterData), []);
29+
30+
const filteredSubscriptions = React.useMemo(() => {
31+
const keyword = filterData[SubscriptionsFilterOptions.keyword]?.toLowerCase();
32+
return keyword
33+
? subscriptions.filter((sub) => sub.name.toLowerCase().includes(keyword))
34+
: subscriptions;
35+
}, [subscriptions, filterData]);
36+
37+
const [deleteSubscription, setDeleteSubscription] = React.useState<MaaSSubscription | undefined>(
38+
undefined,
39+
);
40+
41+
return (
42+
<ApplicationsPage
43+
title="Subscriptions"
44+
description="Subscriptions control access and entitlements to AI model endpoints that are available as a service."
45+
empty={loaded && !error && subscriptions.length === 0}
46+
emptyStatePage={<EmptySubscriptionsPage />}
47+
loaded={loaded}
48+
loadError={error}
49+
>
50+
{loaded && (
51+
<PageSection isFilled>
52+
<SubscriptionsTable
53+
subscriptions={filteredSubscriptions}
54+
onClearFilters={onClearFilters}
55+
toolbarContent={
56+
<SubscriptionsToolbar filterData={filterData} onFilterUpdate={onFilterUpdate} />
57+
}
58+
setDeleteSubscription={setDeleteSubscription}
59+
/>
60+
</PageSection>
61+
)}
62+
{deleteSubscription && (
63+
<DeleteSubscriptionModal
64+
subscription={deleteSubscription}
65+
onClose={() => {
66+
setDeleteSubscription(undefined);
67+
}}
68+
/>
69+
)}
70+
</ApplicationsPage>
71+
);
72+
};
773

874
export default AllSubscriptionsPage;

0 commit comments

Comments
 (0)