Skip to content

Commit fd24a79

Browse files
committed
feat(FR-2672): add AdminDeploymentListPage with extended filters gated by feature flag
1 parent 6eebd5d commit fd24a79

25 files changed

Lines changed: 368 additions & 212 deletions

react/src/components/DeploymentList.tsx

Lines changed: 57 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ 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';
1213
import { Typography, theme } from 'antd';
@@ -17,6 +18,7 @@ import {
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,48 +95,25 @@ 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;
160119
}
@@ -163,15 +122,12 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
163122
deploymentsFrgmt,
164123
filter,
165124
setFilter,
166-
sort,
167-
setSort,
168-
page,
169-
setPage,
170-
pageSize,
171-
setPageSize,
125+
onChangeOrder,
126+
statusCategory = 'running',
127+
onStatusCategoryChange,
172128
mode,
173-
loading,
174129
onRowClick,
130+
...tableProps
175131
}) => {
176132
'use memo';
177133
const { t } = useTranslation();
@@ -198,9 +154,7 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
198154
totalReplicas: replicas {
199155
count
200156
}
201-
runningReplicas: replicas(
202-
filter: { status: { equals: RUNNING } }
203-
) {
157+
runningReplicas: replicas(filter: { status: { equals: RUNNING } }) {
204158
count
205159
}
206160
currentRevision @since(version: "26.4.3") {
@@ -281,7 +235,7 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
281235
{
282236
key: 'name',
283237
title: t('deployment.Name'),
284-
dataIndex: ['metadata', 'name'],
238+
dataIndex: 'name',
285239
sorter: true,
286240
fixed: 'left' as const,
287241
render: (_text, row) => {
@@ -315,9 +269,6 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
315269
const running = row.runningReplicas?.count ?? 0;
316270
const desired = row.replicaState?.desiredReplicaCount ?? 0;
317271
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.
321272
const denominator = desired > 0 ? desired : total;
322273
return (
323274
<Typography.Text>
@@ -335,7 +286,8 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
335286
render: (_text, row) => {
336287
const modelName =
337288
row.currentRevision?.modelMountConfig?.vfolder?.name ?? null;
338-
if (!modelName) return <Typography.Text type="secondary">-</Typography.Text>;
289+
if (!modelName)
290+
return <Typography.Text type="secondary">-</Typography.Text>;
339291
return (
340292
<Typography.Text
341293
ellipsis={{ tooltip: modelName }}
@@ -349,7 +301,7 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
349301
{
350302
key: 'createdAt',
351303
title: t('deployment.CreatedAt'),
352-
dataIndex: ['metadata', 'createdAt'],
304+
dataIndex: 'createdAt',
353305
sorter: true,
354306
render: (_text, row) => {
355307
const createdAt = row.metadata?.createdAt;
@@ -363,38 +315,42 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
363315
},
364316
]);
365317

318+
// Merge fragment-derived total into the pagination config supplied by the
319+
// parent so callers don't need to separately query for count.
320+
const paginationWithTotal =
321+
tableProps.pagination === false
322+
? (false as const)
323+
: { ...tableProps.pagination, total: totalCount };
324+
366325
return (
367326
<BAIFlex direction="column" align="stretch" gap="sm">
327+
<BAIRadioGroup
328+
value={statusCategory}
329+
onChange={(e) => onStatusCategoryChange?.(e.target.value)}
330+
options={[
331+
{ label: t('deployment.Running'), value: 'running' },
332+
{ label: t('deployment.status.Terminated'), value: 'finished' },
333+
]}
334+
/>
368335
<BAIGraphQLPropertyFilter
369336
style={{ marginBottom: token.marginXS }}
370337
filterProperties={filterProperties}
371338
value={filterValue}
372339
onChange={(next) => {
373340
setFilter(stringifyFilter(next));
374-
// Reset pagination when filters change.
375-
setPage(1);
376341
}}
377342
/>
378343
<BAITable<DeploymentNode>
379344
rowKey="id"
380345
scroll={{ x: 'max-content' }}
381-
loading={loading}
346+
showSorterTooltip={false}
347+
{...tableProps}
382348
dataSource={deployments}
383349
columns={columns}
384-
showSorterTooltip={false}
385-
order={sortToTableOrder(sort)}
386350
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-
},
351+
onChangeOrder?.(order || null);
397352
}}
353+
pagination={paginationWithTotal}
398354
/>
399355
</BAIFlex>
400356
);

react/src/components/DeploymentOwnerInfo.tsx

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@
33
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
44
*/
55
import { DeploymentOwnerInfo_deployment$key } from '../__generated__/DeploymentOwnerInfo_deployment.graphql';
6-
import { UserOutlined } from '@ant-design/icons';
7-
import { Avatar, Tooltip, Typography, theme } from 'antd';
8-
import { BAIFlex } from 'backend.ai-ui';
6+
import { Tooltip, Typography } from 'antd';
97
import React from 'react';
108
import { useTranslation } from 'react-i18next';
119
import { graphql, useFragment } from 'react-relay';
@@ -19,7 +17,6 @@ const DeploymentOwnerInfo: React.FC<DeploymentOwnerInfoProps> = ({
1917
}) => {
2018
'use memo';
2119
const { t } = useTranslation();
22-
const { token } = theme.useToken();
2320

2421
const deployment = useFragment(
2522
graphql`
@@ -46,10 +43,6 @@ const DeploymentOwnerInfo: React.FC<DeploymentOwnerInfoProps> = ({
4643
return <Typography.Text type="secondary">-</Typography.Text>;
4744
}
4845

49-
const initial = (fullName || username || email)
50-
.trim()
51-
.charAt(0)
52-
.toUpperCase();
5346
const tooltipLines = [
5447
t('deployment.CreatedBy'),
5548
fullName || username || email,
@@ -59,26 +52,13 @@ const DeploymentOwnerInfo: React.FC<DeploymentOwnerInfoProps> = ({
5952
.join('\n');
6053

6154
return (
62-
<BAIFlex gap="xs" align="center">
63-
<Tooltip
64-
title={<span style={{ whiteSpace: 'pre-line' }}>{tooltipLines}</span>}
65-
>
66-
<Avatar
67-
size="small"
68-
style={{
69-
backgroundColor: token.colorFillSecondary,
70-
color: token.colorText,
71-
flexShrink: 0,
72-
}}
73-
icon={!initial ? <UserOutlined /> : undefined}
74-
>
75-
{initial || null}
76-
</Avatar>
77-
</Tooltip>
78-
<Typography.Text ellipsis={{ tooltip: email }} style={{ maxWidth: 200 }}>
55+
<Tooltip
56+
title={<span style={{ whiteSpace: 'pre-line' }}>{tooltipLines}</span>}
57+
>
58+
<Typography.Text ellipsis={{ tooltip: false }} style={{ maxWidth: 200 }}>
7959
{email}
8060
</Typography.Text>
81-
</BAIFlex>
61+
</Tooltip>
8262
);
8363
};
8464

0 commit comments

Comments
 (0)