Skip to content
Merged
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
24 changes: 11 additions & 13 deletions packages/backend.ai-ui/src/components/BAIGraphQLPropertyFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -475,9 +475,12 @@ const BAIGraphQLPropertyFilter: React.FC<BAIGraphQLPropertyFilterProps> = ({
onChange: propOnChange,
});

const [conditions, setConditions] = useState<FilterCondition[]>(() =>
convertGraphQLFilterToConditions(value, filterProperties),
);
// Reassign sequential ids: the converter generates random ids per call,
// which would change every render and remount every Tag.
const conditions = convertGraphQLFilterToConditions(
value,
filterProperties,
).map((c, i) => ({ ...c, id: `cond-${i}` }));

const [search, setSearch] = useState<string>('');
const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs | null>(null);
Expand Down Expand Up @@ -505,15 +508,11 @@ const BAIGraphQLPropertyFilter: React.FC<BAIGraphQLPropertyFilterProps> = ({
const [isValid, setIsValid] = useState(true);
const [isFocused, setIsFocused] = useState(false);

const propertyOptions = useMemo(
() =>
filterProperties.map((property) => ({
label: property.propertyLabel,
value: property.key,
filter: property,
})),
[filterProperties],
);
const propertyOptions = filterProperties.map((property) => ({
label: property.propertyLabel,
value: property.key,
filter: property,
}));

const availableOperators = useMemo(() => {
const mode = getEffectiveValueMode(selectedProperty);
Expand All @@ -536,7 +535,6 @@ const BAIGraphQLPropertyFilter: React.FC<BAIGraphQLPropertyFilterProps> = ({
}, [availableOperators, t]);

const updateConditions = (newConditions: FilterCondition[]) => {
setConditions(newConditions);
const filter = convertConditionsToGraphQLFilter(
newConditions,
filterProperties,
Expand Down
26 changes: 8 additions & 18 deletions react/src/components/DeploymentConfigurationSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import DeploymentRevisionDetail from './DeploymentRevisionDetail';
import DeploymentRevisionDetailDrawer from './DeploymentRevisionDetailDrawer';
import DeploymentRevisionHistoryTab from './DeploymentRevisionHistoryTab';
import DeploymentSettingModal from './DeploymentSettingModal';
import DeploymentTagChips from './DeploymentTagChips';
import ErrorBoundaryWithNullFallback from './ErrorBoundaryWithNullFallback';
import {
CheckOutlined,
Expand All @@ -24,7 +25,6 @@ import {
Descriptions,
Empty,
Skeleton,
Tag,
Typography,
theme,
} from 'antd';
Expand Down Expand Up @@ -64,12 +64,6 @@ const DeploymentOverviewContent: React.FC<{
const projectName =
deployment?.metadata.projectV2?.basicInfo?.name ??
deployment?.metadata.projectId;
const tags = (deployment?.metadata.tags ?? []).flatMap((tag) =>
tag
.split(',')
.map((t) => t.trim())
.filter(Boolean),
);

const deploymentItems = filterOutEmpty([
{
Expand Down Expand Up @@ -114,16 +108,12 @@ const DeploymentOverviewContent: React.FC<{
{
key: 'tags',
label: t('deployment.Tags'),
children:
tags.length > 0 ? (
<BAIFlex direction="row" wrap="wrap" gap="xxs">
{tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</BAIFlex>
) : (
renderFallback()
),
children: (
<DeploymentTagChips
metadataFrgmt={deployment?.metadata ?? null}
fallback={renderFallback()}
/>
),
},
{
key: 'desired-replicas',
Expand Down Expand Up @@ -299,14 +289,14 @@ const DeploymentConfigurationCards: React.FC<{
...DeploymentSettingModal_deployment
metadata {
name
tags
projectId
domainName
projectV2 @since(version: "26.4.3") {
basicInfo {
name
}
}
...DeploymentTagChips_metadata
}
networkAccess {
openToPublic
Expand Down
59 changes: 14 additions & 45 deletions react/src/components/DeploymentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import { useSuspendedBackendaiClient } from '../hooks';
import BAIRadioGroup from './BAIRadioGroup';
import DeploymentOwnerInfo from './DeploymentOwnerInfo';
import DeploymentStatusTag, { DeploymentStatus } from './DeploymentStatusTag';
import DeploymentTagChips from './DeploymentTagChips';
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
import { Alert, App, Tag, Typography, theme } from 'antd';
import { Alert, App, Typography, theme } from 'antd';
import {
BAIConfirmModalWithInput,
BAIFlex,
Expand Down Expand Up @@ -83,26 +84,6 @@ export const tableOrderToSort = (
return { field, order: descending ? 'DESC' : 'ASC' };
};

const parseFilterString = (
filter: string | undefined,
): GraphQLFilter | undefined => {
if (!filter) return undefined;
try {
const parsed = JSON.parse(filter);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as GraphQLFilter;
}
return undefined;
} catch {
return undefined;
}
};

const stringifyFilter = (filter: GraphQLFilter | undefined): string => {
if (!filter || Object.keys(filter).length === 0) return '';
return JSON.stringify(filter);
};

export type DeploymentStatusCategory = 'running' | 'finished';

export interface DeploymentListProps extends Omit<
Expand All @@ -113,8 +94,8 @@ export interface DeploymentListProps extends Omit<
| DeploymentList_modelDeploymentConnection$key
| null
| undefined;
filter?: string;
setFilter: (value: string) => void;
filter?: GraphQLFilter;
setFilter: (value: GraphQLFilter | null | undefined) => void;
onChangeOrder?: (order: string | null) => void;
statusCategory?: DeploymentStatusCategory;
onStatusCategoryChange?: (value: DeploymentStatusCategory) => void;
Expand Down Expand Up @@ -182,7 +163,7 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
createdAt
domainName
projectId
tags
...DeploymentTagChips_metadata
}
networkAccess {
endpointUrl
Expand Down Expand Up @@ -223,8 +204,6 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
const supportsExtendedFilter =
baiClient?.supports('model-deployment-extended-filter') ?? false;

const filterValue = parseFilterString(filter);

const baseFilterProperties = [
{
key: 'name',
Expand Down Expand Up @@ -372,23 +351,13 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
{
key: 'tags',
title: t('deployment.Tags'),
render: (_text, row) => {
const tags = (row.metadata?.tags ?? []).flatMap((tag) =>
tag
.split(',')
.map((t) => t.trim())
.filter(Boolean),
);
if (tags.length === 0)
return <Typography.Text type="secondary">-</Typography.Text>;
return (
<BAIFlex wrap="wrap" gap="xxs">
{tags.map((tag) => (
<Tag key={tag}>{tag}</Tag>
))}
</BAIFlex>
);
},
render: (_text, row) => (
<DeploymentTagChips
metadataFrgmt={row.metadata}
stopRowClick
fallback={<Typography.Text type="secondary">-</Typography.Text>}
/>
),
},
{
key: 'createdAt',
Expand Down Expand Up @@ -441,9 +410,9 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
/>
<BAIGraphQLPropertyFilter
filterProperties={filterProperties}
value={filterValue}
value={filter}
onChange={(next) => {
setFilter(stringifyFilter(next));
setFilter(next);
}}
/>
</BAIFlex>
Expand Down
99 changes: 99 additions & 0 deletions react/src/components/DeploymentTagChips.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
@license
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
*/
import type { DeploymentTagChips_metadata$key } from '../__generated__/DeploymentTagChips_metadata.graphql';
import { useWebUINavigate } from '../hooks';
import { Tag } from 'antd';
import { BAIFlex } from 'backend.ai-ui';
import React from 'react';
import { graphql, useFragment } from 'react-relay';
import { useLocation } from 'react-router-dom';

interface DeploymentTagChipsProps {
metadataFrgmt: DeploymentTagChips_metadata$key | null | undefined;
/**
* When true, click and Enter/Space keypress events stop bubbling so the
* surrounding row click handler does not also fire (used inside the
* deployment list table).
*/
stopRowClick?: boolean;
/** Rendered when there are no tags to display. */
fallback?: React.ReactNode;
}

/**
* Render a deployment metadata's tags as clickable chips. Activating a chip
* (mouse click or keyboard Enter/Space) navigates to the deployment list
* pre-filtered by that tag using `iContains`. Tag entries are split on
* commas so legacy comma-joined values render as individual chips.
*/
const DeploymentTagChips: React.FC<DeploymentTagChipsProps> = ({
metadataFrgmt,
stopRowClick = false,
fallback = null,
}) => {
'use memo';
const webuiNavigate = useWebUINavigate();
const location = useLocation();

const metadata = useFragment(
graphql`
fragment DeploymentTagChips_metadata on ModelDeploymentMetadata {
tags
}
`,
metadataFrgmt ?? null,
);

const tags = (metadata?.tags ?? []).flatMap((tag) =>
tag
.split(',')
.map((t) => t.trim())
.filter(Boolean),
);

if (tags.length === 0) return <>{fallback}</>;

const navigateToFiltered = (tag: string) => {
const filter = JSON.stringify({ tags: { iContains: tag } });
// Stay within the admin deployments list when activated from there;
// otherwise navigate to the user-facing deployment list.
const targetPathname = location.pathname.startsWith('/admin-deployments')
? '/admin-deployments'
: '/deployments';

Comment thread
yomybaby marked this conversation as resolved.
webuiNavigate({
pathname: targetPathname,
search: new URLSearchParams({ filter }).toString(),
});
};

return (
<BAIFlex wrap="wrap" gap="xxs">
{tags.map((tag) => (
<Tag
key={tag}
role="button"
tabIndex={0}
style={{ cursor: 'pointer' }}
onClick={(e) => {
if (stopRowClick) e.stopPropagation();
navigateToFiltered(tag);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (stopRowClick) e.stopPropagation();
navigateToFiltered(tag);
}
}}
>
{tag}
</Tag>
))}
</BAIFlex>
);
};

export default DeploymentTagChips;
30 changes: 10 additions & 20 deletions react/src/pages/AdminDeploymentListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,31 +30,17 @@ import {
BAICard,
BAIFetchKeyButton,
filterOutEmpty,
GraphQLFilter,
INITIAL_FETCH_KEY,
toLocalId,
useFetchKey,
} from 'backend.ai-ui';
import { parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs';
import { parseAsJson, parseAsStringLiteral, useQueryStates } from 'nuqs';
import React, { Suspense, useDeferredValue, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { graphql, useLazyLoadQuery } from 'react-relay';
import { useSearchParams } from 'react-router-dom';

const parseFilterVariable = (
filter: string | null | undefined,
): DeploymentFilter | undefined => {
if (!filter) return undefined;
try {
const parsed = JSON.parse(filter);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as DeploymentFilter;
}
return undefined;
} catch {
return undefined;
}
};

const AdminDeploymentListPageContent: React.FC = () => {
'use memo';
const webUINavigate = useWebUINavigate();
Expand All @@ -72,7 +58,11 @@ const AdminDeploymentListPageContent: React.FC = () => {

const [queryParams, setQueryParams] = useQueryStates(
{
filter: parseAsString.withDefault(''),
filter: parseAsJson<GraphQLFilter>((value) =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as GraphQLFilter)
: ({} as GraphQLFilter),
),
order: parseAsStringLiteral(availableDeploymentOrderValues),
statusCategory: parseAsStringLiteral<DeploymentStatusCategory>([
'running',
Expand All @@ -96,7 +86,7 @@ const AdminDeploymentListPageContent: React.FC = () => {
: { status: { notIn: finishedStatuses } };
const queryVariables = {
filter: {
...parseFilterVariable(queryParams.filter),
...((queryParams.filter ?? {}) as DeploymentFilter),
...statusCategoryFilter,
},
orderBy: sort
Expand Down Expand Up @@ -150,9 +140,9 @@ const AdminDeploymentListPageContent: React.FC = () => {
<DeploymentList
mode="admin"
deploymentsFrgmt={adminDeployments}
filter={queryParams.filter}
filter={queryParams.filter ?? undefined}
setFilter={(value) => {
setQueryParams({ filter: value || null });
setQueryParams({ filter: value ?? null });
setTablePaginationOption({ current: 1 });
}}
order={queryParams.order ?? undefined}
Expand Down
Loading
Loading