Skip to content

Commit 65154fb

Browse files
committed
feat(FR-2672): add AdminDeploymentListPage with extended filters gated by feature flag
1 parent 56878fd commit 65154fb

25 files changed

Lines changed: 445 additions & 253 deletions

react/src/components/DeploymentList.tsx

Lines changed: 128 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,18 @@ import {
77
DeploymentList_modelDeploymentConnection$key,
88
} from '../__generated__/DeploymentList_modelDeploymentConnection.graphql';
99
import { useSuspendedBackendaiClient } from '../hooks';
10+
import BAIRadioGroup from './BAIRadioGroup';
1011
import DeploymentOwnerInfo from './DeploymentOwnerInfo';
1112
import DeploymentStatusTag, { DeploymentStatus } from './DeploymentStatusTag';
12-
import { Typography, theme } from 'antd';
13+
import { Tag, Typography } from 'antd';
1314
import {
1415
BAIFlex,
1516
BAIGraphQLPropertyFilter,
1617
BAITable,
1718
filterOutEmpty,
1819
filterOutNullAndUndefined,
1920
type BAIColumnType,
21+
type BAITableProps,
2022
type GraphQLFilter,
2123
} from 'backend.ai-ui';
2224
import dayjs from 'dayjs';
@@ -26,26 +28,13 @@ import { useTranslation } from 'react-i18next';
2628
import { graphql, useFragment } from 'react-relay';
2729

2830
type DeploymentEdge = NonNullable<
29-
NonNullable<
30-
DeploymentList_modelDeploymentConnection$data['edges']
31-
>[number]
31+
NonNullable<DeploymentList_modelDeploymentConnection$data['edges']>[number]
3232
>;
3333
type DeploymentNode = NonNullable<DeploymentEdge['node']>;
3434

35-
/**
36-
* Deployment sort direction as emitted by the server-side `DeploymentOrderBy`
37-
* input. Parent pages own the URL state and pass the current value through.
38-
*/
39-
export type DeploymentSortOrder = 'ASC' | 'DESC';
40-
41-
/**
42-
* Structured sort value matching the server-side `DeploymentOrderBy` shape.
43-
* `field` is one of the `DeploymentOrderField` enum values
44-
* (`NAME`, `CREATED_AT`, `DOMAIN`, `PROJECT`, `RESOURCE_GROUP`, `TAG`, ...).
45-
*/
46-
export interface DeploymentSort {
35+
interface DeploymentSort {
4736
field: string;
48-
order: DeploymentSortOrder;
37+
order: 'ASC' | 'DESC';
4938
}
5039

5140
/** Maps BAITable column keys (camelCase) → server-side enum field. */
@@ -58,17 +47,24 @@ const COLUMN_KEY_TO_FIELD: Record<string, string> = {
5847
tag: 'TAG',
5948
};
6049

61-
const FIELD_TO_COLUMN_KEY: Record<string, string> = _.invert(
62-
COLUMN_KEY_TO_FIELD,
63-
);
50+
/** All valid order strings accepted by BAITable for deployments. */
51+
export const availableDeploymentOrderValues = [
52+
'name',
53+
'-name',
54+
'createdAt',
55+
'-createdAt',
56+
] as const;
57+
58+
export type DeploymentOrderValue =
59+
(typeof availableDeploymentOrderValues)[number];
6460

6561
/**
66-
* BAITable exchanges sort state via a single string (e.g. `'name'`,
67-
* `'-createdAt'`). Convert that string to the structured `DeploymentSort`
68-
* shape the parent (and server-side `DeploymentOrderBy`) expects, and vice
69-
* versa.
62+
* Convert a BAITable order string (e.g. `'-createdAt'`) to the structured
63+
* `DeploymentSort` shape expected by the server `DeploymentOrderBy` input.
64+
* Returns `undefined` for unrecognised keys so callers can safely skip
65+
* building the `orderBy` variable.
7066
*/
71-
const tableOrderToSort = (
67+
export const tableOrderToSort = (
7268
order: string | null | undefined,
7369
): DeploymentSort | undefined => {
7470
if (!order) return undefined;
@@ -79,20 +75,6 @@ const tableOrderToSort = (
7975
return { field, order: descending ? 'DESC' : 'ASC' };
8076
};
8177

82-
const sortToTableOrder = (
83-
sort: DeploymentSort | undefined,
84-
): string | undefined => {
85-
if (!sort) return undefined;
86-
const columnKey = FIELD_TO_COLUMN_KEY[sort.field];
87-
if (!columnKey) return undefined;
88-
return sort.order === 'DESC' ? `-${columnKey}` : columnKey;
89-
};
90-
91-
/**
92-
* Safely parse the stringified filter prop into a `GraphQLFilter` object.
93-
* Invalid JSON and non-object values are treated as "no filter" so the
94-
* component degrades gracefully when the URL state is malformed.
95-
*/
9678
const parseFilterString = (
9779
filter: string | undefined,
9880
): GraphQLFilter | undefined => {
@@ -113,69 +95,45 @@ const stringifyFilter = (filter: GraphQLFilter | undefined): string => {
11395
return JSON.stringify(filter);
11496
};
11597

116-
export interface DeploymentListProps {
117-
/**
118-
* Relay fragment reference for a `ModelDeploymentConnection`. The owning
119-
* page (e.g. `DeploymentListPage` / `AdminDeploymentListPage`) passes the
120-
* connection read from its own query.
121-
*/
122-
deploymentsFrgmt: DeploymentList_modelDeploymentConnection$key;
98+
export type DeploymentStatusCategory = 'running' | 'finished';
12399

124-
/**
125-
* Current filter value. Expected to be a JSON-serialized
126-
* `GraphQLFilter` (as produced by `BAIGraphQLPropertyFilter`). Empty string
127-
* or `undefined` means "no filter".
128-
*
129-
* The parent owns the URL state; this component parses on the way in and
130-
* serializes on the way out.
131-
*/
100+
export interface DeploymentListProps extends Omit<
101+
BAITableProps<DeploymentNode>,
102+
'dataSource' | 'columns' | 'onChangeOrder'
103+
> {
104+
deploymentsFrgmt: DeploymentList_modelDeploymentConnection$key;
132105
filter?: string;
133106
setFilter: (value: string) => void;
134-
135-
/** Current server-side sort (field + direction). */
136-
sort?: DeploymentSort;
137-
setSort: (value: DeploymentSort | undefined) => void;
138-
139-
/** 1-indexed page number. */
140-
page: number;
141-
setPage: (value: number) => void;
142-
143-
/** Rows per page. */
144-
pageSize: number;
145-
setPageSize: (value: number) => void;
146-
107+
onChangeOrder?: (order: string | null) => void;
108+
statusCategory?: DeploymentStatusCategory;
109+
onStatusCategoryChange?: (value: DeploymentStatusCategory) => void;
147110
/**
148111
* `'user'` — standard user-owned list (myDeployments / projectDeployments).
149112
* `'admin'` — admin list. Shows the Owner column and — when the manager
150113
* supports `model-deployment-extended-filter` (>= 26.4.3) — exposes
151114
* Domain / Project / Resource Group filters.
152115
*/
153116
mode: 'user' | 'admin';
154-
155-
/** Whether the table body should show the loading spinner. */
156-
loading?: boolean;
157-
158117
/** Called when a row name is clicked. Receives the deployment global ID. */
159118
onRowClick?: (deploymentId: string) => void;
119+
/** Extra elements rendered at the end of the toolbar row (e.g. refresh + create buttons). */
120+
toolbarEnd?: React.ReactNode;
160121
}
161122

162123
const DeploymentList: React.FC<DeploymentListProps> = ({
163124
deploymentsFrgmt,
164125
filter,
165126
setFilter,
166-
sort,
167-
setSort,
168-
page,
169-
setPage,
170-
pageSize,
171-
setPageSize,
127+
onChangeOrder,
128+
statusCategory = 'running',
129+
onStatusCategoryChange,
172130
mode,
173-
loading,
174131
onRowClick,
132+
toolbarEnd,
133+
...tableProps
175134
}) => {
176135
'use memo';
177136
const { t } = useTranslation();
178-
const { token } = theme.useToken();
179137
const baiClient = useSuspendedBackendaiClient();
180138

181139
const connection = useFragment(
@@ -191,16 +149,18 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
191149
createdAt
192150
domainName
193151
projectId
152+
tags
153+
}
154+
networkAccess {
155+
endpointUrl
194156
}
195157
replicaState {
196158
desiredReplicaCount
197159
}
198160
totalReplicas: replicas {
199161
count
200162
}
201-
runningReplicas: replicas(
202-
filter: { status: { equals: RUNNING } }
203-
) {
163+
runningReplicas: replicas(filter: { status: { equals: RUNNING } }) {
204164
count
205165
}
206166
currentRevision @since(version: "26.4.3") {
@@ -281,7 +241,7 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
281241
{
282242
key: 'name',
283243
title: t('deployment.Name'),
284-
dataIndex: ['metadata', 'name'],
244+
dataIndex: 'name',
285245
sorter: true,
286246
fixed: 'left' as const,
287247
render: (_text, row) => {
@@ -315,9 +275,6 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
315275
const running = row.runningReplicas?.count ?? 0;
316276
const desired = row.replicaState?.desiredReplicaCount ?? 0;
317277
const total = row.totalReplicas?.count ?? desired;
318-
// Prefer desired count as the denominator so ongoing (scaling)
319-
// deployments still surface the intended replica target. Fall back
320-
// to the observed total if desired is not reported.
321278
const denominator = desired > 0 ? desired : total;
322279
return (
323280
<Typography.Text>
@@ -335,7 +292,8 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
335292
render: (_text, row) => {
336293
const modelName =
337294
row.currentRevision?.modelMountConfig?.vfolder?.name ?? null;
338-
if (!modelName) return <Typography.Text type="secondary">-</Typography.Text>;
295+
if (!modelName)
296+
return <Typography.Text type="secondary">-</Typography.Text>;
339297
return (
340298
<Typography.Text
341299
ellipsis={{ tooltip: modelName }}
@@ -346,56 +304,107 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
346304
);
347305
},
348306
},
307+
{
308+
key: 'endpointUrl',
309+
title: t('deployment.EndpointUrl'),
310+
render: (_text, row) => {
311+
const url = row.networkAccess?.endpointUrl;
312+
if (!url) return <Typography.Text type="secondary">-</Typography.Text>;
313+
return (
314+
<Typography.Link href={url} target="_blank" rel="noreferrer">
315+
{url}
316+
</Typography.Link>
317+
);
318+
},
319+
},
320+
{
321+
key: 'tags',
322+
title: t('deployment.Tags'),
323+
render: (_text, row) => {
324+
const tags = row.metadata?.tags ?? [];
325+
if (tags.length === 0)
326+
return <Typography.Text type="secondary">-</Typography.Text>;
327+
return (
328+
<BAIFlex wrap="wrap" gap="xs">
329+
{tags.map((tag) => (
330+
<Tag key={tag}>{tag}</Tag>
331+
))}
332+
</BAIFlex>
333+
);
334+
},
335+
},
349336
{
350337
key: 'createdAt',
351338
title: t('deployment.CreatedAt'),
352-
dataIndex: ['metadata', 'createdAt'],
339+
dataIndex: 'createdAt',
353340
sorter: true,
354341
render: (_text, row) => {
355342
const createdAt = row.metadata?.createdAt;
356343
return createdAt ? dayjs(createdAt).format('ll LT') : '-';
357344
},
358345
},
346+
isAdminMode && {
347+
key: 'domainName',
348+
title: t('deployment.Domain'),
349+
render: (_text, row) => {
350+
const domain = row.metadata?.domainName;
351+
return domain ? (
352+
<Typography.Text>{domain}</Typography.Text>
353+
) : (
354+
<Typography.Text type="secondary">-</Typography.Text>
355+
);
356+
},
357+
},
359358
isAdminMode && {
360359
key: 'owner',
361360
title: t('deployment.Owner'),
362361
render: (_text, row) => <DeploymentOwnerInfo deploymentFrgmt={row} />,
363362
},
364363
]);
365364

365+
// Merge fragment-derived total into the pagination config supplied by the
366+
// parent so callers don't need to separately query for count.
367+
const paginationWithTotal =
368+
tableProps.pagination === false
369+
? (false as const)
370+
: { ...tableProps.pagination, total: totalCount };
371+
366372
return (
367373
<BAIFlex direction="column" align="stretch" gap="sm">
368-
<BAIGraphQLPropertyFilter
369-
style={{ marginBottom: token.marginXS }}
370-
filterProperties={filterProperties}
371-
value={filterValue}
372-
onChange={(next) => {
373-
setFilter(stringifyFilter(next));
374-
// Reset pagination when filters change.
375-
setPage(1);
376-
}}
377-
/>
378-
<BAITable<DeploymentNode>
379-
rowKey="id"
380-
scroll={{ x: 'max-content' }}
381-
loading={loading}
382-
dataSource={deployments}
383-
columns={columns}
384-
showSorterTooltip={false}
385-
order={sortToTableOrder(sort)}
386-
onChangeOrder={(order) => {
387-
setSort(tableOrderToSort(order));
388-
}}
389-
pagination={{
390-
current: page,
391-
pageSize,
392-
total: totalCount,
393-
onChange: (nextPage, nextPageSize) => {
394-
if (nextPage !== page) setPage(nextPage);
395-
if (nextPageSize !== pageSize) setPageSize(nextPageSize);
396-
},
397-
}}
398-
/>
374+
<BAIFlex justify="between" wrap="wrap" gap="sm">
375+
<BAIFlex gap="sm" align="start" wrap="wrap" style={{ flexShrink: 1 }}>
376+
<BAIRadioGroup
377+
value={statusCategory}
378+
onChange={(e) => onStatusCategoryChange?.(e.target.value)}
379+
options={[
380+
{ label: t('deployment.Running'), value: 'running' },
381+
{ label: t('deployment.status.Terminated'), value: 'finished' },
382+
]}
383+
/>
384+
<BAIGraphQLPropertyFilter
385+
filterProperties={filterProperties}
386+
value={filterValue}
387+
onChange={(next) => {
388+
setFilter(stringifyFilter(next));
389+
}}
390+
/>
391+
</BAIFlex>
392+
{toolbarEnd}
393+
</BAIFlex>
394+
<div style={{ overflowX: 'auto' }}>
395+
<BAITable<DeploymentNode>
396+
rowKey="id"
397+
scroll={{ x: 'max-content' }}
398+
showSorterTooltip={false}
399+
{...tableProps}
400+
dataSource={deployments}
401+
columns={columns}
402+
onChangeOrder={(order) => {
403+
onChangeOrder?.(order || null);
404+
}}
405+
pagination={paginationWithTotal}
406+
/>
407+
</div>
399408
</BAIFlex>
400409
);
401410
};

0 commit comments

Comments
 (0)