From 7f77831528b2544bc2f882f115ff29ac5ca18207 Mon Sep 17 00:00:00 2001 From: agatha197 Date: Fri, 20 Jun 2025 12:29:35 +0900 Subject: [PATCH 1/2] feat(FR-1093): migrate filebrowser and sftp server caller function to react --- config.toml.sample | 1 + data/merged_schema.graphql | 170 +++++++++-- data/schema.graphql | 189 ++++++++++-- .../TerminateSessionModal.tsx | 4 +- react/src/components/FileBrowserButton.tsx | 285 ++++++++++++++++++ react/src/components/FolderExplorerHeader.tsx | 28 +- react/src/components/LegacyFolderExplorer.tsx | 69 +++-- react/src/components/SessionDetailContent.tsx | 6 + react/src/hooks/index.tsx | 1 + react/src/hooks/useBackendAIAppLauncher.tsx | 2 +- react/src/pages/SessionLauncherPage.tsx | 13 +- src/components/backend-ai-folder-explorer.ts | 2 - src/components/backend-ai-import-view.ts | 2 +- src/components/backend-ai-login.ts | 10 + src/lib/backend.ai-client-esm.ts | 16 +- src/types/backend-ai-console.d.ts | 1 + 16 files changed, 698 insertions(+), 101 deletions(-) create mode 100644 react/src/components/FileBrowserButton.tsx diff --git a/config.toml.sample b/config.toml.sample index 04d51177b7..c5bef10806 100644 --- a/config.toml.sample +++ b/config.toml.sample @@ -32,6 +32,7 @@ enableExtendLoginSession = false # If true, enables login session extensi enableImportFromHuggingFace = false # Enable import from Hugging Face feature. (From Backend.AI 24.09) enableInteractiveLoginAccountSwitch = true # If false, hide the "Sign in with a different account" button from the interactive login page. enableModelFolders = true # Enable model folders feature. (From Backend.AI 23.03) +defaultFileBrowserImage = "" # Default file browser image. If not specified, an arbitrary installed file browser image will be used. [wsproxy] proxyURL = "[Proxy URL]" diff --git a/data/merged_schema.graphql b/data/merged_schema.graphql index 748bfd9449..7b034a7411 100644 --- a/data/merged_schema.graphql +++ b/data/merged_schema.graphql @@ -328,11 +328,23 @@ type Queries { """Added in 25.1.0.""" endpoint_auto_scaling_rule_nodes(endpoint: String!, filter: String, order: String, offset: Int, before: String, after: String, first: Int, last: Int): EndpointAutoScalingRuleConnection - """Added in 25.5.0.""" + """Added in 25.6.0.""" user_utilization_metric(user_id: UUID!, props: UserUtilizationMetricQueryInput!): UserUtilizationMetric - """Added in 25.5.0.""" + """Added in 25.6.0.""" container_utilization_metric_metadata: ContainerUtilizationMetricMetadata + + """Added in 25.8.0.""" + available_service: AvailableServiceNode + + """Added in 25.8.0.""" + available_services(filter: String, order: String, offset: Int, before: String, after: String, first: Int, last: Int): AvailableServiceConnection + + """Added in 25.8.0.""" + service_config(service: String!): ServiceConfigNode + + """Added in 25.8.0.""" + service_configs(services: [String]!, filter: String, order: String, offset: Int, before: String, after: String, first: Int, last: Int): ServiceConfigConnection } """ @@ -405,32 +417,32 @@ type AuditLogNode implements Node { """The ID of the object""" id: ID! - """UUID of the audit log row""" + """UUID of the AuditLog row""" row_id: UUID! """Added in 25.6.0. UUID of the action""" action_id: UUID! - """Entity ID of the AuditLog""" + """Entity type of the AuditLog""" entity_type: String! - """Entity type of the AuditLog""" + """Operation type of the AuditLog""" operation: String! - """Operation type of the AuditLog""" - entity_id: String! + """Entity ID of the AuditLog""" + entity_id: String """The time the AuditLog was reported""" created_at: DateTime! - """RequestID of the AuditLog""" - request_id: UUID! + """Request ID of the AuditLog""" + request_id: String """Description of the AuditLog""" description: String! """Duration taken to perform the operation""" - duration: String! + duration: String """Status of the AuditLog""" status: String! @@ -586,6 +598,9 @@ type ImageNode implements Node { Added in 25.3.0. One of ['read_attribute', 'update_attribute', 'create_container', 'forget_image']. """ permissions: [ImagePermissionValueField] + + """Added in 25.11.0. Indicates if the image is installed on any Agent.""" + installed: Boolean } type KVPair { @@ -2041,19 +2056,19 @@ type EndpointAutoScalingRuleEdge { cursor: String! } -"""Added in 25.5.0.""" +"""Added in 25.6.0.""" type UserUtilizationMetric { user_id: UUID metrics: [ContainerUtilizationMetric] } -"""Added in 25.5.0.""" +"""Added in 25.6.0.""" type ContainerUtilizationMetric { metric_name: String - """One of 'current', 'capacity', 'pct'.""" + """One of 'current', 'capacity'.""" value_type: String - values: [MetircResultValue] + values: [MetricResultValue] """The maximum value of the metric in given time range. null if no data.""" max_value: String @@ -2062,16 +2077,16 @@ type ContainerUtilizationMetric { avg_value: String } -"""Added in 25.5.0. A pair of timestamp and value.""" -type MetircResultValue { +"""Added in 25.6.0. A pair of timestamp and value.""" +type MetricResultValue { timestamp: Float value: String } -"""Added in 25.5.0.""" +"""Added in 25.6.0.""" input UserUtilizationMetricQueryInput { - """One of 'current', 'capacity', 'pct'. Default value is 'null'.""" - value_type: String = null + """One of 'current', 'capacity'. Default value is 'current'.""" + value_type: String = "current" """metric name of container utilization. For example, 'cpu_util', 'mem'.""" metric_name: String! @@ -2088,11 +2103,83 @@ input UserUtilizationMetricQueryInput { step: String! } -"""Added in 25.5.0.""" +"""Added in 25.6.0.""" type ContainerUtilizationMetricMetadata { metric_names: [String] } +"""Available services for configuration. Added in 25.8.0.""" +type AvailableServiceNode implements Node { + """The ID of the object""" + id: ID! + + """Possible values of "Config.service". Added in 25.8.0.""" + service_variants: [String]! +} + +"""Added in 25.8.0.""" +type AvailableServiceConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [AvailableServiceEdge]! + + """Total count of the GQL nodes of the query.""" + count: Int +} + +""" +Added in 25.8.0. A Relay edge containing a `AvailableService` and its cursor. +""" +type AvailableServiceEdge { + """The item at the end of the edge""" + node: AvailableServiceNode + + """A cursor for use in pagination""" + cursor: String! +} + +"""Configuration data for a specific service. Added in 25.8.0.""" +type ServiceConfigNode implements Node { + """The ID of the object""" + id: ID! + + """ + Service name. See AvailableService.service_variants for possible values. Added in 25.8.0. + """ + service: String! + + """Configuration data. Added in 25.8.0.""" + configuration: JSONString! + + """JSON schema of the configuration. Added in 25.8.0.""" + schema: JSONString! +} + +"""Added in 25.8.0.""" +type ServiceConfigConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [ServiceConfigEdge]! + + """Total count of the GQL nodes of the query.""" + count: Int +} + +""" +Added in 25.8.0. A Relay edge containing a `ServiceConfig` and its cursor. +""" +type ServiceConfigEdge { + """The item at the end of the edge""" + node: ServiceConfigNode + + """A cursor for use in pagination""" + cursor: String! +} + """All available GraphQL mutations.""" type Mutations { modify_agent(id: String!, props: ModifyAgentInput!): ModifyAgent @@ -2172,6 +2259,9 @@ type Mutations { unload_image(references: [String]!, target_agents: [String]!): UnloadImage modify_image(architecture: String = "x86_64", props: ModifyImageInput!, target: String!): ModifyImage + """Added in 25.6.0""" + clear_image_custom_resource_limit(key: ClearImageCustomResourceLimitKey!): ClearImageCustomResourceLimitPayload + """Added in 24.03.0""" forget_image_by_id(image_id: String!): ForgetImageById @@ -2213,6 +2303,12 @@ type Mutations { id: UUID = null name: String = null @deprecated(reason: "Deprecated since 25.4.0.") ): DeleteResourcePreset + + """Updates configuration for a given service. Added in 25.8.0.""" + modify_service_config( + """Added in 25.8.0.""" + input: ModifyServiceConfigNodeInput! + ): ModifyServiceConfigNodePayload create_scaling_group(name: String!, props: CreateScalingGroupInput!): CreateScalingGroup modify_scaling_group(name: String!, props: ModifyScalingGroupInput!): ModifyScalingGroup delete_scaling_group(name: String!): DeleteScalingGroup @@ -2741,6 +2837,17 @@ input ResourceLimitInput { max: String } +"""Added in 25.6.0.""" +type ClearImageCustomResourceLimitPayload { + image_node: ImageNode +} + +"""Added in 25.6.0.""" +input ClearImageCustomResourceLimitKey { + image_canonical: String! + architecture: String! = "x86_64" +} + """Added in 24.03.0.""" type ForgetImageById { ok: Boolean @@ -3036,6 +3143,29 @@ type DeleteResourcePreset { msg: String } +""" +Payload for the ModifyServiceConfigNode mutation. +Added in 25.8.0. +""" +type ModifyServiceConfigNodePayload { + """ServiceConfiguration Node. Added in 25.8.0.""" + service_config: ServiceConfigNode! +} + +""" +Input data for modifying configuration. +Added in 25.8.0. +""" +input ModifyServiceConfigNodeInput { + """ + Service name. See AvailableService.service_variants for possible values. Added in 25.8.0. + """ + service: String! + + """Service configuration data to mutate. Added in 25.8.0.""" + configuration: JSONString! +} + type CreateScalingGroup { ok: Boolean msg: String diff --git a/data/schema.graphql b/data/schema.graphql index 88349b6715..2869afb263 100644 --- a/data/schema.graphql +++ b/data/schema.graphql @@ -328,11 +328,23 @@ type Queries { """Added in 25.1.0.""" endpoint_auto_scaling_rule_nodes(endpoint: String!, filter: String, order: String, offset: Int, before: String, after: String, first: Int, last: Int): EndpointAutoScalingRuleConnection - """Added in 25.5.0.""" + """Added in 25.6.0.""" user_utilization_metric(user_id: UUID!, props: UserUtilizationMetricQueryInput!): UserUtilizationMetric - """Added in 25.5.0.""" + """Added in 25.6.0.""" container_utilization_metric_metadata: ContainerUtilizationMetricMetadata + + """Added in 25.8.0.""" + available_service: AvailableServiceNode + + """Added in 25.8.0.""" + available_services(filter: String, order: String, offset: Int, before: String, after: String, first: Int, last: Int): AvailableServiceConnection + + """Added in 25.8.0.""" + service_config(service: String!): ServiceConfigNode + + """Added in 25.8.0.""" + service_configs(services: [String]!, filter: String, order: String, offset: Int, before: String, after: String, first: Int, last: Int): ServiceConfigConnection } """ @@ -405,32 +417,32 @@ type AuditLogNode implements Node { """The ID of the object""" id: ID! - """UUID of the audit log row""" + """UUID of the AuditLog row""" row_id: UUID! """Added in 25.6.0. UUID of the action""" action_id: UUID! - """Entity ID of the AuditLog""" + """Entity type of the AuditLog""" entity_type: String! - """Entity type of the AuditLog""" + """Operation type of the AuditLog""" operation: String! - """Operation type of the AuditLog""" - entity_id: String! + """Entity ID of the AuditLog""" + entity_id: String """The time the AuditLog was reported""" created_at: DateTime! - """RequestID of the AuditLog""" - request_id: UUID! + """Request ID of the AuditLog""" + request_id: String """Description of the AuditLog""" description: String! """Duration taken to perform the operation""" - duration: String! + duration: String """Status of the AuditLog""" status: String! @@ -586,6 +598,9 @@ type ImageNode implements Node { Added in 25.3.0. One of ['read_attribute', 'update_attribute', 'create_container', 'forget_image']. """ permissions: [ImagePermissionValueField] + + """Added in 25.11.0. Indicates if the image is installed on any Agent.""" + installed: Boolean } type KVPair { @@ -2041,19 +2056,19 @@ type EndpointAutoScalingRuleEdge { cursor: String! } -"""Added in 25.5.0.""" +"""Added in 25.6.0.""" type UserUtilizationMetric { user_id: UUID metrics: [ContainerUtilizationMetric] } -"""Added in 25.5.0.""" +"""Added in 25.6.0.""" type ContainerUtilizationMetric { metric_name: String - """One of 'current', 'capacity', 'pct'.""" + """One of 'current', 'capacity'.""" value_type: String - values: [MetircResultValue] + values: [MetricResultValue] """The maximum value of the metric in given time range. null if no data.""" max_value: String @@ -2062,16 +2077,16 @@ type ContainerUtilizationMetric { avg_value: String } -"""Added in 25.5.0. A pair of timestamp and value.""" -type MetircResultValue { +"""Added in 25.6.0. A pair of timestamp and value.""" +type MetricResultValue { timestamp: Float value: String } -"""Added in 25.5.0.""" +"""Added in 25.6.0.""" input UserUtilizationMetricQueryInput { - """One of 'current', 'capacity', 'pct'. Default value is 'null'.""" - value_type: String = null + """One of 'current', 'capacity'. Default value is 'current'.""" + value_type: String = "current" """metric name of container utilization. For example, 'cpu_util', 'mem'.""" metric_name: String! @@ -2088,11 +2103,83 @@ input UserUtilizationMetricQueryInput { step: String! } -"""Added in 25.5.0.""" +"""Added in 25.6.0.""" type ContainerUtilizationMetricMetadata { metric_names: [String] } +"""Available services for configuration. Added in 25.8.0.""" +type AvailableServiceNode implements Node { + """The ID of the object""" + id: ID! + + """Possible values of "Config.service". Added in 25.8.0.""" + service_variants: [String]! +} + +"""Added in 25.8.0.""" +type AvailableServiceConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [AvailableServiceEdge]! + + """Total count of the GQL nodes of the query.""" + count: Int +} + +""" +Added in 25.8.0. A Relay edge containing a `AvailableService` and its cursor. +""" +type AvailableServiceEdge { + """The item at the end of the edge""" + node: AvailableServiceNode + + """A cursor for use in pagination""" + cursor: String! +} + +"""Configuration data for a specific service. Added in 25.8.0.""" +type ServiceConfigNode implements Node { + """The ID of the object""" + id: ID! + + """ + Service name. See AvailableService.service_variants for possible values. Added in 25.8.0. + """ + service: String! + + """Configuration data. Added in 25.8.0.""" + configuration: JSONString! + + """JSON schema of the configuration. Added in 25.8.0.""" + schema: JSONString! +} + +"""Added in 25.8.0.""" +type ServiceConfigConnection { + """Pagination data for this connection.""" + pageInfo: PageInfo! + + """Contains the nodes in this connection.""" + edges: [ServiceConfigEdge]! + + """Total count of the GQL nodes of the query.""" + count: Int +} + +""" +Added in 25.8.0. A Relay edge containing a `ServiceConfig` and its cursor. +""" +type ServiceConfigEdge { + """The item at the end of the edge""" + node: ServiceConfigNode + + """A cursor for use in pagination""" + cursor: String! +} + """All available GraphQL mutations.""" type Mutations { modify_agent(id: String!, props: ModifyAgentInput!): ModifyAgent @@ -2172,6 +2259,9 @@ type Mutations { unload_image(references: [String]!, target_agents: [String]!): UnloadImage modify_image(architecture: String = "x86_64", props: ModifyImageInput!, target: String!): ModifyImage + """Added in 25.6.0""" + clear_image_custom_resource_limit(key: ClearImageCustomResourceLimitKey!): ClearImageCustomResourceLimitPayload + """Added in 24.03.0""" forget_image_by_id(image_id: String!): ForgetImageById @@ -2179,7 +2269,12 @@ type Mutations { forget_image(architecture: String = "x86_64", reference: String!): ForgetImage @deprecated(reason: "Deprecated since 25.4.0. Use `forget_image_by_id` instead.") """Added in 25.4.0""" - purge_image_by_id(image_id: String!): PurgeImageById + purge_image_by_id( + image_id: String! + + """Added in 25.10.0.""" + options: PurgeImageOptions = {remove_from_registry: false} + ): PurgeImageById """Added in 24.03.1""" untag_image_from_registry(image_id: String!): UntagImageFromRegistry @@ -2213,6 +2308,12 @@ type Mutations { id: UUID = null name: String = null @deprecated(reason: "Deprecated since 25.4.0.") ): DeleteResourcePreset + + """Updates configuration for a given service. Added in 25.8.0.""" + modify_service_config( + """Added in 25.8.0.""" + input: ModifyServiceConfigNodeInput! + ): ModifyServiceConfigNodePayload create_scaling_group(name: String!, props: CreateScalingGroupInput!): CreateScalingGroup modify_scaling_group(name: String!, props: ModifyScalingGroupInput!): ModifyScalingGroup delete_scaling_group(name: String!): DeleteScalingGroup @@ -2741,6 +2842,17 @@ input ResourceLimitInput { max: String } +"""Added in 25.6.0.""" +type ClearImageCustomResourceLimitPayload { + image_node: ImageNode +} + +"""Added in 25.6.0.""" +input ClearImageCustomResourceLimitKey { + image_canonical: String! + architecture: String! = "x86_64" +} + """Added in 24.03.0.""" type ForgetImageById { ok: Boolean @@ -2764,7 +2876,17 @@ type PurgeImageById { image: ImageNode } -"""Added in 24.03.1""" +"""Added in 25.10.0.""" +input PurgeImageOptions { + """ + Untag the deleted image from the registry. Only available in the HarborV2 registry. + """ + remove_from_registry: Boolean = false +} + +""" +Deprecated since 25.10.0. Use `purge_image_by_id` with `remove_from_registry` option instead. +""" type UntagImageFromRegistry { ok: Boolean msg: String @@ -3036,6 +3158,29 @@ type DeleteResourcePreset { msg: String } +""" +Payload for the ModifyServiceConfigNode mutation. +Added in 25.8.0. +""" +type ModifyServiceConfigNodePayload { + """ServiceConfiguration Node. Added in 25.8.0.""" + service_config: ServiceConfigNode! +} + +""" +Input data for modifying configuration. +Added in 25.8.0. +""" +input ModifyServiceConfigNodeInput { + """ + Service name. See AvailableService.service_variants for possible values. Added in 25.8.0. + """ + service: String! + + """Service configuration data to mutate. Added in 25.8.0.""" + configuration: JSONString! +} + type CreateScalingGroup { ok: Boolean msg: String diff --git a/react/src/components/ComputeSessionNodeItems/TerminateSessionModal.tsx b/react/src/components/ComputeSessionNodeItems/TerminateSessionModal.tsx index 129b29af72..0bdc26605b 100644 --- a/react/src/components/ComputeSessionNodeItems/TerminateSessionModal.tsx +++ b/react/src/components/ComputeSessionNodeItems/TerminateSessionModal.tsx @@ -220,7 +220,7 @@ const TerminateSessionModal: React.FC = ({ const { pendingCount, trackPromise } = usePromiseTracker(); - const terminiateSession = (session: SessionForTerminateModal) => { + const terminateSession = (session: SessionForTerminateModal) => { return terminateApp( session, baiClient._config.accessKey, @@ -262,7 +262,7 @@ const TerminateSessionModal: React.FC = ({ const promises = _.map( filterEmptyItem(_.castArray(sessions)), (session) => { - return terminiateSession(session) + return terminateSession(session) .catch((err) => { upsertNotification({ message: painKiller.relieve(err?.title), diff --git a/react/src/components/FileBrowserButton.tsx b/react/src/components/FileBrowserButton.tsx new file mode 100644 index 0000000000..97f46d3f75 --- /dev/null +++ b/react/src/components/FileBrowserButton.tsx @@ -0,0 +1,285 @@ +import { + FileBrowserButtonImageNodeFragment$key, + FileBrowserButtonImageNodeFragment$data, +} from '../__generated__/FileBrowserButtonImageNodeFragment.graphql'; +import { addNumberWithUnits } from '../helper'; +import { useCurrentDomainValue, useSuspendedBackendaiClient } from '../hooks'; +import { useSetBAINotification } from '../hooks/useBAINotification'; +import { useCurrentProjectValue } from '../hooks/useCurrentProject'; +import { + generateSessionId, + SessionResources, +} from '../pages/SessionLauncherPage'; +import { Button, Tooltip, Image, Grid, App } from 'antd'; +import _ from 'lodash'; +import React, { LegacyRef, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useFragment } from 'react-relay'; + +type ResourceLimits = NonNullable< + NonNullable[number]['resource_limits'] +>; + +const RESOURCE_DEFAULTS = { + CPU: 1, + MEMORY: '256m', + SHMEM: '64m', + FALLBACK_MEMORY: '320m', +} as const; + +const calculateResources = ( + resourceLimits: ResourceLimits | null, + labels: any, +) => { + const limits = resourceLimits || []; + const cpu = + limits.find((r) => r?.key === 'cpu')?.min || RESOURCE_DEFAULTS.CPU; + const shmem = + labels?.['ai.backend.resource.preferred.shmem'] || RESOURCE_DEFAULTS.SHMEM; + const baseMem = + limits.find((r) => r?.key === 'mem')?.min || RESOURCE_DEFAULTS.MEMORY; + + return { + cpu: cpu as number, + mem: + addNumberWithUnits(baseMem, shmem, 'm') || + RESOURCE_DEFAULTS.FALLBACK_MEMORY, + }; +}; + +const createSessionConfig = ( + vfolderName: string, + domain: string, + projectName: string, + resourceLimits: ResourceLimits | null, + architecture?: string, +): SessionResources => ({ + cluster_mode: 'single-node', + cluster_size: 1, + domain, + group_name: projectName, + ...(architecture && { architecture }), + config: { + mounts: [vfolderName], + resources: { + ..._.mapValues(_.keyBy(resourceLimits || [], 'key'), 'min'), + ...calculateResources(resourceLimits, {}), + }, + }, +}); + +interface FileBrowserButtonProps { + vfolderName: string; + folderExplorerRef: LegacyRef; + imageNodesFrgmt?: FileBrowserButtonImageNodeFragment$key | null; +} + +const FileBrowserButton: React.FC = ({ + vfolderName, + folderExplorerRef, + imageNodesFrgmt, +}) => { + const baiClient = useSuspendedBackendaiClient(); + const currentDomain = useCurrentDomainValue(); + const currentProject = useCurrentProjectValue(); + const { message } = App.useApp(); + const { t } = useTranslation(); + const { upsertNotification } = useSetBAINotification(); + const { lg } = Grid.useBreakpoint(); + + const imageNodes = useFragment( + graphql` + fragment FileBrowserButtonImageNodeFragment on ImageNode + @relay(plural: true) { + id + name @deprecatedSince(version: "24.12.0") + namespace @since(version: "24.12.0") + # installed @since(version: "25.11.0") + installed @since(version: "24.11.0") + registry + tag + architecture + resource_limits { + key + min + } + labels { + key + value + } + } + `, + imageNodesFrgmt, + ); + + const installedFilebrowserImage = _.find(imageNodes, (node) => + _.get(node, 'installed', false), + ); + + const filebrowserConfig = useMemo(() => { + const resourceLimits = _.get( + installedFilebrowserImage, + 'resource_limits', + [], + ); + const { cpu, mem } = calculateResources( + _.toArray(resourceLimits ?? []), + _.get(installedFilebrowserImage, 'labels', {}), + ); + const [defaultFileBrowserImage, architecture] = _.split( + baiClient._config?.defaultFileBrowserImage, + '@', + ); + const filebrowserImage = defaultFileBrowserImage + ? defaultFileBrowserImage + : `${_.get(installedFilebrowserImage, 'registry')}/${_.get(installedFilebrowserImage, 'namespace') ?? _.get(installedFilebrowserImage, 'name')}:${_.get(installedFilebrowserImage, 'tag')}`; + + return { + installedFilebrowserImage, + resourceLimits, + cpu, + mem, + filebrowserImage, + architecture, + }; + }, [installedFilebrowserImage, baiClient._config?.defaultFileBrowserImage]); + + const sessionName = useMemo(() => generateSessionId(), []); + + const resources = useMemo( + () => + createSessionConfig( + vfolderName, + currentDomain, + currentProject?.name, + _.toArray(filebrowserConfig.resourceLimits ?? []), + filebrowserConfig.architecture, + ), + [vfolderName, currentDomain, currentProject?.name, filebrowserConfig], + ); + + const executeFileBrowser = async () => { + if (!filebrowserConfig.filebrowserImage) { + message.error(t('data.explorer.NoImagesSupportingFileBrowser')); + return; + } + + const sessionPromise = baiClient + .createIfNotExists( + filebrowserConfig.filebrowserImage, + sessionName, + resources, + 30000, + ) + .then((res: { created: boolean; status: string }) => { + // When session is already created with the same name, the status code + // is 200, but the response body has 'created' field as false. For better + // user experience, we show the notification message. + if (!res?.created) { + // message.warning(t('session.launcher.SessionAlreadyExists')); + throw new Error(t('session.launcher.SessionAlreadyExists')); + } + if (res?.status === 'CANCELLED') { + // Case about failed to start new session kind of "docker image not found" or etc. + throw new Error(t('session.launcher.FailedToStartNewSession')); + } + return res; + }) + .catch((err: any) => { + if (err?.message?.includes('The session already exists')) { + throw new Error(t('session.launcher.SessionAlreadyExists')); + } else { + throw err; + } + }); + + upsertNotification({ + key: 'session-launcher:' + sessionName, + backgroundTask: { + promise: sessionPromise, + status: 'pending', + onChange: { + pending: t('session.PreparingSession'), + resolved: t('eduapi.ComputeSessionPrepared'), + }, + }, + duration: 0, + message: t('general.Session') + ': ' + sessionName, + open: true, + }); + + let backupTo = ''; + + return await sessionPromise + .then( + (res: { + servicePorts: Array<{ + name: string; + }>; + sessionId: string; + }) => { + // After the session is created, add a "See Details" button to navigate to the session page. + upsertNotification({ + key: 'session-launcher:' + sessionName, + to: { + pathname: '/session', + search: new URLSearchParams({ + sessionDetail: res.sessionId, + }).toString(), + }, + }); + backupTo = `/session?sessionDetail=${res.sessionId}`; + const servicePorts = res?.servicePorts; + const appOptions = { + 'session-uuid': res.sessionId, + 'session-name': sessionName, + 'access-key': '', + runtime: 'filebrowser', + arguments: { '--root': '/home/work/' + vfolderName }, + }; + // only launch filebrowser app when it has valid service ports + if ( + servicePorts?.length && + _.filter(servicePorts, (el) => el?.name === 'filebrowser').length + ) { + // @ts-ignore + globalThis.appLauncher.showLauncher(appOptions); + } + }, + ) + .catch(() => { + upsertNotification({ + key: 'session-launcher:' + sessionName, + to: backupTo, + toText: t('button.Edit'), + }); + }); + }; + + // Make sure to return the JSX element + return ( + + + + ); +}; + +export default FileBrowserButton; diff --git a/react/src/components/FolderExplorerHeader.tsx b/react/src/components/FolderExplorerHeader.tsx index 4a45889cde..886c270a4a 100644 --- a/react/src/components/FolderExplorerHeader.tsx +++ b/react/src/components/FolderExplorerHeader.tsx @@ -1,4 +1,6 @@ +import { FileBrowserButtonImageNodeFragment$key } from '../__generated__/FileBrowserButtonImageNodeFragment.graphql'; import { FolderExplorerHeaderFragment$key } from '../__generated__/FolderExplorerHeaderFragment.graphql'; +import FileBrowserButton from './FileBrowserButton'; import Flex from './Flex'; import VFolderNameTitle from './VFolderNameTitle'; import { Button, Tooltip, Image, Skeleton, Grid, theme } from 'antd'; @@ -8,11 +10,13 @@ import { graphql, useFragment } from 'react-relay'; interface FolderExplorerHeaderProps { vfolderNodeFrgmt?: FolderExplorerHeaderFragment$key | null; + imageNodesFrgmt?: FileBrowserButtonImageNodeFragment$key | null; folderExplorerRef: LegacyRef; } const FolderExplorerHeader: React.FC = ({ vfolderNodeFrgmt, + imageNodesFrgmt, folderExplorerRef, }) => { const { t } = useTranslation(); @@ -26,6 +30,7 @@ const FolderExplorerHeader: React.FC = ({ user permission unmanaged_path @since(version: "25.04.0") + name ...VFolderNameTitleNodeFragment } `, @@ -48,24 +53,11 @@ const FolderExplorerHeader: React.FC = ({ > {!vfolderNode?.unmanaged_path ? ( <> - - - + - ) : null} diff --git a/react/src/components/LegacyFolderExplorer.tsx b/react/src/components/LegacyFolderExplorer.tsx index 8c5e80d6b4..02632592c0 100644 --- a/react/src/components/LegacyFolderExplorer.tsx +++ b/react/src/components/LegacyFolderExplorer.tsx @@ -76,13 +76,16 @@ const LegacyFolderExplorer: React.FC = ({ }; }, []); - const { vfolder_node, image_nodes } = + // TODO: check default file browser and ssh image from config and filter the image nodes. + // Else use the installed image nodes with the label "ai.backend.service-ports == filebrowser", "ai.backend.role == SYSTEM". + const { vfolder_node, fileBrowserImageNodes, sftpImageNodes } = useLazyLoadQuery( graphql` query LegacyFolderExplorerQuery( $vfolderUUID: String! $scope_id: ScopeField! - $filebrowserFilter: String + $fileBrowserFilter: String + $sftpFilter: String ) { vfolder_node(id: $vfolderUUID) @since(version: "24.03.4") { id @@ -93,30 +96,71 @@ const LegacyFolderExplorer: React.FC = ({ ...VFolderNodeDescriptionFragment ...VFolderNameTitleNodeFragment } - image_nodes(scope_id: $scope_id, filter: $filebrowserFilter) { + fileBrowserImageNodes: image_nodes( + scope_id: $scope_id + filter: $fileBrowserFilter + ) { edges { node { id @required(action: THROW) + labels { + key + value + } + installed @since(version: "25.11.0") ...FileBrowserButtonImageNodeFragment } } } + sftpImageNodes: image_nodes( + scope_id: $scope_id + filter: $sftpFilter + ) { + edges { + node { + id @required(action: THROW) + labels { + key + value + } + installed @since(version: "25.11.0") + ...SFTPButtonImageNodeFragment + } + } + } } `, { vfolderUUID: toGlobalId('VirtualFolderNode', vfolderID), scope_id: `project:${currentProject?.id}`, - filebrowserFilter: baiClient.isManagerVersionCompatibleWith('24.12.0') - ? `namespace ilike "%filebrowser%"` - : `name ilike "%filebrowser%"`, + // TODO: find the image nodes with the label "ai.backend.service-ports == filebrowser" + // fileBrowserFilter: + // TODO: find the image nodes with the label "ai.backend.role == "SYSTEM" + // sftpFilter: }, { fetchPolicy: modalProps.open ? 'network-only' : 'store-only', }, ); - const imageNodes = filterEmptyItem( - _.map(image_nodes?.edges, (edge) => edge?.node), + const installedFileBrowserImageNodes = filterEmptyItem( + _.map( + _.filter(fileBrowserImageNodes?.edges, (node) => + // For backward compatibility, set default installed value to true. + _.get(node, 'installed', true), + ), + (node) => _.get(node, 'node', null), + ), + ); + + const installedSFTPImageNodes = filterEmptyItem( + _.map( + _.filter(sftpImageNodes?.edges, (node) => + // For backward compatibility, set default installed value to true. + _.get(node, 'installed', true), + ), + (node) => _.get(node, 'node', null), + ), ); const legacyFolderExplorerPane = ( @@ -161,9 +205,13 @@ const LegacyFolderExplorer: React.FC = ({ footer={null} title={ } onCancel={() => { diff --git a/react/src/components/SFTPButton.tsx b/react/src/components/SFTPButton.tsx new file mode 100644 index 0000000000..ac38e0ad74 --- /dev/null +++ b/react/src/components/SFTPButton.tsx @@ -0,0 +1,219 @@ +import { SFTPButtonImageNodeFragment$key } from '../__generated__/SFTPButtonImageNodeFragment.graphql'; +import { createSessionConfig } from '../helper'; +import { useSuspendedBackendaiClient, useCurrentDomainValue } from '../hooks'; +import { useSetBAINotification } from '../hooks/useBAINotification'; +import { + useCurrentProjectValue, + useResourceGroupsForCurrentProject, +} from '../hooks/useCurrentProject'; +import { generateSessionId } from '../pages/SessionLauncherPage'; +import SFTPInfoModal from './SFTPInfoModal'; +import { Image, App, Button, Grid, Tooltip } from 'antd'; +import _ from 'lodash'; +import { LegacyRef, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useFragment } from 'react-relay'; + +interface SFTPButtonProps { + vfolderName: string; + folderExplorerRef: LegacyRef; + imageNodeFrgmt?: SFTPButtonImageNodeFragment$key | null; +} + +const SFTPButton: React.FC = ({ + vfolderName, + folderExplorerRef, + imageNodeFrgmt, +}) => { + const baiClient = useSuspendedBackendaiClient(); + const currentDomain = useCurrentDomainValue(); + const currentProject = useCurrentProjectValue(); + const { message } = App.useApp(); + const { t } = useTranslation(); + const { upsertNotification } = useSetBAINotification(); + const { lg } = Grid.useBreakpoint(); + const { allSftpScalingGroups } = useResourceGroupsForCurrentProject(); + const [sessionId, setSessionId] = useState(null); + + const imageNode = useFragment( + graphql` + fragment SFTPButtonImageNodeFragment on ImageNode { + id + name @deprecatedSince(version: "24.12.0") + namespace @since(version: "24.12.0") + registry + tag + architecture + resource_limits { + key + min + } + labels { + key + value + } + } + `, + imageNodeFrgmt, + ); + + const sftpConfig = useMemo(() => { + const resourceLimits = _.get(imageNode, 'resource_limits', []); + const [defaultSSHImage, architecture] = _.split( + baiClient._config?.systemSSHImage, + '@', + ); + const SFTPImage = defaultSSHImage + ? defaultSSHImage + : `${_.get(imageNode, 'registry')}/${_.get(imageNode, 'namespace') ?? _.get(imageNode, 'name')}:${_.get(imageNode, 'tag')}`; + + return { + imageNode, + resourceLimits, + SFTPImage, + architecture: + architecture || _.get(imageNode, 'architecture', 'x86_64') || 'x86_64', + }; + }, [imageNode, baiClient._config?.systemSSHImage]); + + const sessionName = useMemo(() => generateSessionId(), []); + + const resources = useMemo( + () => + createSessionConfig( + vfolderName, + currentDomain, + currentProject?.name, + _.toArray(sftpConfig.resourceLimits ?? []), + sftpConfig.architecture, + _.get(sftpConfig.imageNode, 'labels', {}), + { + type: 'system', + config: { + // TODO: SFTP scaling group should be selected by user + scaling_group: allSftpScalingGroups?.[0], + }, + }, + ), + [ + vfolderName, + currentDomain, + currentProject?.name, + sftpConfig.resourceLimits, + sftpConfig.architecture, + allSftpScalingGroups, + sftpConfig.imageNode, + ], + ); + + const executeSFTP = async () => { + if (!sftpConfig.SFTPImage) { + message.error(t('data.explorer.SFTPSessionNotAvailable')); + return; + } + + const sessionPromise = baiClient + .createIfNotExists(sftpConfig.SFTPImage, sessionName, resources, 10000) + .then((res: { created: boolean; status: string }) => { + // When session is already created with the same name, the status code + // is 200, but the response body has 'created' field as false. For better + // user experience, we show the notification message. + if (!res?.created) { + // message.warning(t('session.launcher.SessionAlreadyExists')); + throw new Error(t('session.launcher.SessionAlreadyExists')); + } + if (res?.status === 'CANCELLED') { + // Case about failed to start new session kind of "docker image not found" or etc. + throw new Error( + t('data.explorer.NumberOfSFTPSessionsExceededTitle'), + { + cause: t('data.explorer.NumberOfSFTPSessionsExceededBody'), + }, + ); + } + return res; + }) + .catch((err: any) => { + if (err?.message?.includes('The session already exists')) { + throw new Error(t('session.launcher.SessionAlreadyExists')); + } else { + throw err; + } + }); + + upsertNotification({ + key: 'session-launcher:' + sessionName, + backgroundTask: { + promise: sessionPromise, + status: 'pending', + onChange: { + pending: t('session.PreparingSession'), + resolved: t('eduapi.ComputeSessionPrepared'), + }, + }, + duration: 0, + message: t('general.Session') + ': ' + sessionName, + open: true, + }); + + let backupTo = ''; + + return await sessionPromise + .then((res: { sessionId: string }) => { + upsertNotification({ + key: 'session-launcher:' + sessionName, + to: { + pathname: '/session', + search: new URLSearchParams({ + sessionDetail: res.sessionId, + }).toString(), + }, + }); + backupTo = `/session?sessionDetail=${res.sessionId}`; + setSessionId(res.sessionId); + }) + .catch(() => { + upsertNotification({ + key: 'session-launcher:' + sessionName, + to: backupTo, + toText: t('button.Edit'), + }); + }); + }; + + return ( + <> + + + + {sessionId && ( + setSessionId(null)} + /> + )} + + ); +}; + +export default SFTPButton; diff --git a/react/src/components/SFTPInfoModal.tsx b/react/src/components/SFTPInfoModal.tsx new file mode 100644 index 0000000000..9692db622f --- /dev/null +++ b/react/src/components/SFTPInfoModal.tsx @@ -0,0 +1,106 @@ +import { + useDownloadIDContainer, + useSFTPDirectAccessInfo, +} from '../hooks/backendai'; +import BAIModal, { BAIModalProps } from './BAIModal'; +import Flex from './Flex'; +import { Button, Typography, Alert, theme } from 'antd'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface SFTPInfoModalProps extends BAIModalProps { + sessionId: string; + mountedVfolderName: string; +} + +const SFTPInfoModal: React.FC = ({ + sessionId, + mountedVfolderName, +}) => { + const { t } = useTranslation(); + const { token } = theme.useToken(); + const { host, port } = useSFTPDirectAccessInfo(sessionId); + const downloadIDContainer = useDownloadIDContainer(sessionId); + const [expanded, setExpanded] = useState(false); + + const sftpCommand = `sftp -i ./id_container -P ${port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null work@${host}`; + + const scpCommand = `scp -i ./id_container -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -P ${port} -rp /path/to/source work@${host}:~/${mountedVfolderName}`; + + const rsyncCommand = `rsync -av -e "ssh -i ./id_container -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${port}" /path/to/source/ work@${host}:~/${mountedVfolderName}/`; + + return ( + + setExpanded(info.expanded), + }} + > + {t('session.SFTPDescription')} + + + + + {t('session.ConnectionInformation')} + + + + User: work + + + Host: {host} + + + Port: {port} + + + + + {t('session.ConnectionExample')} + + + +
{sftpCommand}
+
+ +
+ + +
{scpCommand}
+
+ +
+ + +
{rsyncCommand}
+
+ +
+ + +
+ ); +}; + +export default SFTPInfoModal; diff --git a/react/src/helper/index.tsx b/react/src/helper/index.tsx index d5bc3de776..c686e75ded 100644 --- a/react/src/helper/index.tsx +++ b/react/src/helper/index.tsx @@ -1,7 +1,10 @@ +import { FileBrowserButtonImageNodeFragment$data } from '../__generated__/FileBrowserButtonImageNodeFragment.graphql'; +import { SFTPButtonImageNodeFragment$data } from '../__generated__/SFTPButtonImageNodeFragment.graphql'; import { CommittedImage } from '../components/CustomizedImageList'; import { Image } from '../components/ImageEnvironmentSelectFormItems'; import { EnvironmentImage } from '../components/ImageList'; import { useSuspendedBackendaiClient } from '../hooks'; +import { SessionResources } from '../pages/SessionLauncherPage'; import { AttachmentsProps } from '@ant-design/x'; import { fetchEventSource } from '@microsoft/fetch-event-source'; import { SorterResult } from 'antd/es/table/interface'; @@ -696,3 +699,73 @@ export function listenToBackgroundTask< return controller.abort.bind(controller); } +type ResourceLimits = + | NonNullable< + NonNullable['resource_limits'] + > + | NonNullable< + NonNullable['resource_limits'] + >; + +const RESOURCE_DEFAULTS = { + CPU: 1, + MEMORY: '256m', + SHMEM: '64m', + FALLBACK_MEMORY: '320m', +} as const; + +export const calculateResources = ( + resourceLimits: ResourceLimits | null, + labels: any, +) => { + const limits = resourceLimits || []; + const cpu = + limits.find((r) => r?.key === 'cpu')?.min || RESOURCE_DEFAULTS.CPU; + const shmem = + labels?.['ai.backend.resource.preferred.shmem'] || RESOURCE_DEFAULTS.SHMEM; + const baseMem = + limits.find((r) => r?.key === 'mem')?.min || RESOURCE_DEFAULTS.MEMORY; + + return { + cpu: cpu as number, + mem: + addNumberWithUnits(baseMem, shmem, 'm') || + RESOURCE_DEFAULTS.FALLBACK_MEMORY, + shmem: shmem as string, + }; +}; + +export const createSessionConfig = ( + mountedVfolders: Array | string, + domain: string, + projectName: string, + resourceLimits: ResourceLimits | null, + architecture?: string, + labels?: any, + rest?: Partial, +): SessionResources => { + const { cpu, mem, shmem } = calculateResources(resourceLimits, labels); + return { + cluster_mode: 'single-node', + cluster_size: 1, + domain, + group_name: projectName, + ...(architecture && { architecture }), + ...rest, + config: { + ...rest?.config, + mounts: + typeof mountedVfolders === 'string' + ? [mountedVfolders] + : mountedVfolders, + resources: { + ..._.mapValues(_.keyBy(resourceLimits || [], 'key'), 'min'), + cpu, + mem, + }, + resource_opts: { + shmem, + }, + }, + }; +}; diff --git a/react/src/hooks/backendai.tsx b/react/src/hooks/backendai.tsx index 1b300e8118..c3bf7b865e 100644 --- a/react/src/hooks/backendai.tsx +++ b/react/src/hooks/backendai.tsx @@ -383,3 +383,39 @@ export const useVFolderInvitations = (fetchKey?: string) => { }, ] as const; }; + +export const useSFTPDirectAccessInfo = (sessionId: string) => { + const baiClient = useSuspendedBackendaiClient(); + const { data: directAccessInfo, isLoading } = useTanQuery<{ + public_host: string; + sshd_ports: number | string; + [key: string]: any; + }>({ + queryKey: ['directAccessInfo', sessionId], + queryFn: () => { + return baiClient.get_direct_access_info(sessionId); + }, + }); + + const host = directAccessInfo?.public_host?.replace(/^https?:\/\//, '') ?? ''; + const port = directAccessInfo?.sshd_ports; + + return { directAccessInfo, isLoading, host, port }; +}; + +export const useDownloadIDContainer = (sessionId: string) => { + const baiClient = useSuspendedBackendaiClient(); + const file = '/home/work/id_container'; + + const downloadBlob = async () => { + const blob = await baiClient.download_single(sessionId, file); + if (_.isNil(blob)) { + throw new Error('Failed to download blob'); + } + const rawText = await blob.text(); + const trimmedBlob = rawText.slice(rawText.indexOf('-----')); + return new Blob([trimmedBlob], { type: blob.type }); + }; + + return downloadBlob; +}; diff --git a/react/src/hooks/useSessionButton.tsx b/react/src/hooks/useSessionButton.tsx new file mode 100644 index 0000000000..743bc95f68 --- /dev/null +++ b/react/src/hooks/useSessionButton.tsx @@ -0,0 +1,170 @@ +import { createSessionConfig } from '../helper'; +import { generateSessionId } from '../pages/SessionLauncherPage'; +import { useSuspendedBackendaiClient, useCurrentDomainValue } from './index'; +import { useSetBAINotification } from './useBAINotification'; +import { useCurrentProjectValue } from './useCurrentProject'; +import { App } from 'antd'; +import _ from 'lodash'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export type SessionType = 'filebrowser' | 'sftp'; + +interface SessionButtonConfig { + vfolderName: string; + sessionType: SessionType; + installedImage: any; + defaultImage?: string; + resourceLimits: any[]; + architecture?: string; + labels?: Record; + additionalSessionConfig?: any; +} + +interface UseSessionButtonReturn { + sessionName: string; + resources: any; + executeSession: () => Promise; + config: { + installedImage: any; + resourceLimits: any[]; + image: string; + architecture?: string; + }; +} + +export const useSessionButton = ({ + vfolderName, + sessionType, + installedImage, + defaultImage, + resourceLimits, + architecture, + labels = {}, + additionalSessionConfig, +}: SessionButtonConfig): UseSessionButtonReturn => { + const baiClient = useSuspendedBackendaiClient(); + const currentDomain = useCurrentDomainValue(); + const currentProject = useCurrentProjectValue(); + const { message } = App.useApp(); + const { t } = useTranslation(); + const { upsertNotification } = useSetBAINotification(); + + const sessionName = useMemo(() => generateSessionId(), []); + + const config = useMemo(() => { + const image = defaultImage + ? defaultImage + : `${_.get(installedImage, 'registry')}/${_.get(installedImage, 'namespace') ?? _.get(installedImage, 'name')}:${_.get(installedImage, 'tag')}`; + + return { + installedImage, + resourceLimits, + image, + architecture, + }; + }, [installedImage, defaultImage, resourceLimits, architecture]); + + const resources = useMemo( + () => + createSessionConfig( + vfolderName, + currentDomain, + currentProject?.name, + _.toArray(config.resourceLimits ?? []), + config.architecture, + labels, + additionalSessionConfig, + ), + [ + vfolderName, + currentDomain, + currentProject?.name, + config.resourceLimits, + config.architecture, + labels, + additionalSessionConfig, + ], + ); + + const executeSession = async (): Promise => { + if (!config.image) { + const errorMessage = + sessionType === 'filebrowser' + ? t('data.explorer.NoImagesSupportingFileBrowser') + : t('data.explorer.SFTPSessionNotAvailable'); + message.error(errorMessage); + return; + } + + const sessionPromise = baiClient + .createIfNotExists(config.image, sessionName, resources, 30000) + .then((res: { created: boolean; status: string }) => { + if (!res?.created) { + throw new Error(t('session.launcher.SessionAlreadyExists')); + } + if (res?.status === 'CANCELLED') { + const errorMessage = + sessionType === 'filebrowser' + ? t('session.launcher.FailedToStartNewSession') + : t('data.explorer.NumberOfSFTPSessionsExceededTitle'); + throw new Error(errorMessage); + } + return res; + }) + .catch((err: any) => { + if (err?.message?.includes('The session already exists')) { + throw new Error(t('session.launcher.SessionAlreadyExists')); + } else { + throw err; + } + }); + + upsertNotification({ + key: 'session-launcher:' + sessionName, + backgroundTask: { + promise: sessionPromise, + status: 'pending', + onChange: { + pending: t('session.PreparingSession'), + resolved: t('eduapi.ComputeSessionPrepared'), + }, + }, + duration: 0, + message: t('general.Session') + ': ' + sessionName, + open: true, + }); + + let backupTo = ''; + + return await sessionPromise + .then((res: any) => { + upsertNotification({ + key: 'session-launcher:' + sessionName, + to: { + pathname: '/session', + search: new URLSearchParams({ + sessionDetail: res.sessionId, + }).toString(), + }, + }); + backupTo = `/session?sessionDetail=${res.sessionId}`; + return { ...res, sessionName }; + }) + .catch(() => { + upsertNotification({ + key: 'session-launcher:' + sessionName, + to: backupTo, + toText: t('button.Edit'), + }); + throw new Error('Session creation failed'); + }); + }; + + return { + sessionName, + resources, + executeSession, + config, + }; +}; diff --git a/react/src/pages/ServingPage.tsx b/react/src/pages/ServingPage.tsx index b8718d4023..1be60b0b5e 100644 --- a/react/src/pages/ServingPage.tsx +++ b/react/src/pages/ServingPage.tsx @@ -13,6 +13,7 @@ import { useCurrentUserRole } from '../hooks/backendai'; import { useBAIPaginationOptionStateOnSearchParam } from '../hooks/reactPaginationQueryOptions'; import { useCurrentProjectValue } from '../hooks/useCurrentProject'; import { useDeferredQueryParams } from '../hooks/useDeferredQueryParams'; +import { operators } from 'ajv/dist/compile/codegen'; import { Button, Skeleton, theme, Typography } from 'antd'; import _ from 'lodash'; import React, { Suspense, useDeferredValue, useMemo } from 'react';