diff --git a/data/schema.graphql b/data/schema.graphql index 7fa786903b..0c5fe5a88b 100644 --- a/data/schema.graphql +++ b/data/schema.graphql @@ -140,6 +140,23 @@ Added in 24.12.0. One of ['read_attribute', 'update_attribute', 'create_compute_ scalar AgentPermissionField @join__type(graph: GRAPHENE) +"""Added in 25.15.0""" +type AgentResource + @join__type(graph: STRAWBERRY) +{ + free: JSON! + used: JSON! + capacity: JSON! +} + +"""Added in 25.15.0""" +type AgentStats + @join__type(graph: STRAWBERRY) +{ + """Added in 25.15.0""" + totalResource: AgentResource! +} + """A schema for normal users.""" type AgentSummary implements Item @join__implements(graph: GRAPHENE, interface: "Item") @@ -295,6 +312,47 @@ type ArtifactRegistry type: ArtifactRegistryType! } +""" +Added in 25.15.0. + +Represents common metadata for an artifact registry. +All artifact registry nodes expose that information regardless of type. +""" +type ArtifactRegistryMeta implements Node + @join__implements(graph: STRAWBERRY, interface: "Node") + @join__type(graph: STRAWBERRY) +{ + """The Globally Unique ID of this object""" + id: ID! + name: String! + registryId: ID! + type: ArtifactRegistryType! + url: String! +} + +"""Added in 25.15.0""" +type ArtifactRegistryMetaConnection + @join__type(graph: STRAWBERRY) +{ + """Pagination data for this connection""" + pageInfo: PageInfo! + + """Contains the nodes in this connection""" + edges: [ArtifactRegistryMetaEdge!]! + count: Int! +} + +"""An edge in a connection.""" +type ArtifactRegistryMetaEdge + @join__type(graph: STRAWBERRY) +{ + """A cursor for use in pagination""" + cursor: String! + + """The item at the end of the edge""" + node: ArtifactRegistryMeta! +} + enum ArtifactRegistryType @join__type(graph: STRAWBERRY) { @@ -302,6 +360,13 @@ enum ArtifactRegistryType RESERVOIR @join__enumValue(graph: STRAWBERRY) } +enum ArtifactRemoteStatus + @join__type(graph: STRAWBERRY) +{ + SCANNED @join__enumValue(graph: STRAWBERRY) + AVAILABLE @join__enumValue(graph: STRAWBERRY) +} + """Added in 25.14.0""" type ArtifactRevision implements Node @join__implements(graph: STRAWBERRY, interface: "Node") @@ -310,6 +375,9 @@ type ArtifactRevision implements Node """The Globally Unique ID of this object""" id: ID! status: ArtifactStatus! + + """Added in 25.15.0""" + remoteStatus: ArtifactRemoteStatus version: String! readme: String size: ByteSize @@ -1401,6 +1469,9 @@ type CreateUser ok: Boolean msg: String user: User + + """Added in 25.15.0.""" + keypair: KeyPair } type CreateUserResourcePolicy @@ -1452,6 +1523,87 @@ type DealiasImage msg: String } +"""Added in 25.15.0""" +input DelegateeTarget + @join__type(graph: STRAWBERRY) +{ + delegateeReservoirId: ID! + targetRegistryId: ID! +} + +""" +Added in 25.15.0. + +Input type for delegated import of artifact revisions from a reservoir registry's remote registry. +Used to specify which artifact revisions should be imported from the remote registry source +into the local reservoir registry storage. +""" +input DelegateImportArtifactsInput + @join__type(graph: STRAWBERRY) +{ + """List of artifact revision IDs of delegatee artifact registry""" + artifactRevisionIds: [ID!]! +} + +""" +Added in 25.15.0. + +Response payload for delegated artifact import operation. +Contains the imported artifact revisions and associated background tasks. +The tasks can be monitored to track the progress of the import operation. +""" +type DelegateImportArtifactsPayload + @join__type(graph: STRAWBERRY) +{ + """ + Connection of artifact revisions that were imported from the reservoir registry's remote registry + """ + artifactRevisions: ArtifactRevisionConnection! + + """List of background tasks created for importing the artifact revisions""" + tasks: [ArtifactRevisionImportTask!]! +} + +""" +Added in 25.15.0. + +Input type for delegated scanning of artifacts from a delegatee reservoir registry's remote registry. +""" +input DelegateScanArtifactsInput + @join__type(graph: STRAWBERRY) +{ + """ID of the reservoir registry to delegate the scan request to""" + delegatorReservoirId: ID = null + + """Target delegatee reservoir registry and its remote registry to scan""" + delegateeTarget: DelegateeTarget = null + + """Maximum number of artifacts to scan (max: 500)""" + limit: Int! + + """Filter artifacts by type (e.g., model, image, package)""" + artifactType: ArtifactType = null + + """Search term to filter artifacts by name or description""" + search: String = null +} + +""" +Added in 25.15.0. + +Response payload for delegated artifact scanning operation. +Contains the list of artifacts discovered during the scan of a reservoir registry's remote registry. +These artifacts are now available for import or direct use. +""" +type DelegateScanArtifactsPayload + @join__type(graph: STRAWBERRY) +{ + """ + List of artifacts discovered during the delegated scan from the reservoir registry's remote registry + """ + artifacts: [Artifact!]! +} + """Added in 25.15.0""" input DeleteArtifactsInput @join__type(graph: STRAWBERRY) @@ -2429,6 +2581,13 @@ enum join__Graph { STRAWBERRY @join__graph(name: "strawberry", url: "http://host.docker.internal:8091/admin/gql/strawberry") } +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](https://ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf). +""" +scalar JSON + @join__type(graph: STRAWBERRY) + @specifiedBy(url: "https://ecma-international.org/wp-content/uploads/ECMA-404_2nd_edition_december_2017.pdf") + """ Allows use of a JSON String for input / output from the GraphQL schema. @@ -3766,6 +3925,37 @@ type Mutation """Added in 25.14.0""" importArtifacts(input: ImportArtifactsInput!): ImportArtifactsPayload! @join__field(graph: STRAWBERRY) + """ + Added in 25.15.0. + + Triggers artifact scanning on a remote reservoir registry. + + This mutation instructs a reservoir-type registry to initiate a scan of artifacts + from its associated remote reservoir registry source. The scan process will discover and + catalog artifacts available in the remote reservoir, making them accessible + through the local reservoir registry. + + Requirements: + - The delegator registry must be of type 'reservoir' + - The delegator reservoir registry must have a valid remote registry configuration + """ + delegateScanArtifacts(input: DelegateScanArtifactsInput!): DelegateScanArtifactsPayload! @join__field(graph: STRAWBERRY) + + """ + Added in 25.15.0. + + Trigger import of artifact revisions from a remote reservoir registry. + + This mutation instructs a reservoir-type registry to import specific artifact revisions + that were previously discovered during a scan from its remote registry. + Note that this operation does not import the artifacts directly into the local registry, but only into the delegator reservoir's storage. + + Requirements: + - The delegator registry must be of type 'reservoir' + - The delegator registry must have a valid remote registry configuration + """ + delegateImportArtifacts(input: DelegateImportArtifactsInput!): DelegateImportArtifactsPayload! @join__field(graph: STRAWBERRY) + """Added in 25.14.0""" updateArtifact(input: UpdateArtifactInput!): UpdateArtifactPayload! @join__field(graph: STRAWBERRY) @@ -3794,11 +3984,19 @@ type Mutation """Added in 25.14.0""" deleteObjectStorage(input: DeleteObjectStorageInput!): DeleteObjectStoragePayload! @join__field(graph: STRAWBERRY) - """Added in 25.14.0""" - registerObjectStorageBucket(input: RegisterObjectStorageBucketInput!): RegisterObjectStorageBucketPayload! @join__field(graph: STRAWBERRY) + """ + Added in 25.15.0. + + Registers a new namespace within a storage. + """ + registerStorageNamespace(input: RegisterStorageNamespaceInput!): RegisterStorageNamespacePayload! @join__field(graph: STRAWBERRY) - """Added in 25.14.0""" - unregisterObjectStorageBucket(input: UnregisterObjectStorageBucketInput!): UnregisterObjectStorageBucketPayload! @join__field(graph: STRAWBERRY) + """ + Added in 25.15.0. + + Unregisters an existing namespace from a storage. + """ + unregisterStorageNamespace(input: UnregisterStorageNamespaceInput!): UnregisterStorageNamespacePayload! @join__field(graph: STRAWBERRY) """Added in 25.14.0""" createHuggingfaceRegistry(input: CreateHuggingFaceRegistryInput!): CreateHuggingFaceRegistryPayload! @join__field(graph: STRAWBERRY) @@ -3899,7 +4097,7 @@ type ObjectStorage implements Node secretKey: String! endpoint: String! region: String! - namespaces(before: String, after: String, first: Int, last: Int, limit: Int, offset: Int): ObjectStorageNamespaceConnection! + namespaces(before: String, after: String, first: Int, last: Int, limit: Int, offset: Int): StorageNamespaceConnection! } """Added in 25.14.0""" @@ -3925,40 +4123,6 @@ type ObjectStorageEdge node: ObjectStorage! } -"""Added in 25.14.0""" -type ObjectStorageNamespace implements Node - @join__implements(graph: STRAWBERRY, interface: "Node") - @join__type(graph: STRAWBERRY) -{ - """The Globally Unique ID of this object""" - id: ID! - storageId: ID! - bucket: String! -} - -"""Added in 25.14.0""" -type ObjectStorageNamespaceConnection - @join__type(graph: STRAWBERRY) -{ - """Pagination data for this connection""" - pageInfo: PageInfo! - - """Contains the nodes in this connection""" - edges: [ObjectStorageNamespaceEdge!]! - count: Int! -} - -"""An edge in a connection.""" -type ObjectStorageNamespaceEdge - @join__type(graph: STRAWBERRY) -{ - """A cursor for use in pagination""" - cursor: String! - - """The item at the end of the edge""" - node: ObjectStorageNamespace! -} - enum OrderDirection @join__type(graph: STRAWBERRY) { @@ -4572,6 +4736,9 @@ type Query """Added in 25.14.0""" defaultArtifactRegistry(artifactType: ArtifactType!): ArtifactRegistry @join__field(graph: STRAWBERRY) + + """Added in 25.15.0""" + agentStats: AgentStats! @join__field(graph: STRAWBERRY) } type QuotaDetails @@ -4605,16 +4772,24 @@ type RawServiceConfig extraCliParameters: String } -"""Added in 25.14.0""" -input RegisterObjectStorageBucketInput +""" +Added in 25.15.0. + +Input type for registering a storage namespace. +""" +input RegisterStorageNamespaceInput @join__type(graph: STRAWBERRY) { storageId: UUID! - bucketName: String! + namespace: String! } -"""Added in 25.14.0""" -type RegisterObjectStorageBucketPayload +""" +Added in 25.15.0. + +Payload returned after storage namespace registration. +""" +type RegisterStorageNamespacePayload @join__type(graph: STRAWBERRY) { id: UUID! @@ -4681,6 +4856,7 @@ type ReservoirRegistry implements Node accessKey: String! secretKey: String! apiVersion: String! + remoteArtifactRegistries: ArtifactRegistryMetaConnection! } """Added in 25.14.0""" @@ -5062,6 +5238,53 @@ type SourceInfo url: String } +""" +Added in 25.15.0. + +Storage namespace provides logical separation of data within a single storage system +to organize and isolate domain-specific concerns. + +Implementation varies by storage type: +- Object Storage (S3, MinIO): Uses bucket-based namespace separation +- File System (VFS): Uses directory path prefix for namespace distinction +""" +type StorageNamespace implements Node + @join__implements(graph: STRAWBERRY, interface: "Node") + @join__type(graph: STRAWBERRY) +{ + """The Globally Unique ID of this object""" + id: ID! + storageId: ID! + namespace: String! +} + +""" +Added in 25.15.0. + +Storage namespace connection for pagination. +""" +type StorageNamespaceConnection + @join__type(graph: STRAWBERRY) +{ + """Pagination data for this connection""" + pageInfo: PageInfo! + + """Contains the nodes in this connection""" + edges: [StorageNamespaceEdge!]! + count: Int! +} + +"""An edge in a connection.""" +type StorageNamespaceEdge + @join__type(graph: STRAWBERRY) +{ + """A cursor for use in pagination""" + cursor: String! + + """The item at the end of the edge""" + node: StorageNamespace! +} + type StorageVolume implements Item @join__implements(graph: GRAPHENE, interface: "Item") @join__type(graph: GRAPHENE) @@ -5135,16 +5358,24 @@ type UnloadImage task_id: String } -"""Added in 25.14.0""" -input UnregisterObjectStorageBucketInput +""" +Added in 25.15.0. + +Input type for unregistering a storage namespace. +""" +input UnregisterStorageNamespaceInput @join__type(graph: STRAWBERRY) { storageId: UUID! - bucketName: String! + namespace: String! } -"""Added in 25.14.0""" -type UnregisterObjectStorageBucketPayload +""" +Added in 25.15.0. + +Payload returned after storage namespace unregistration. +""" +type UnregisterStorageNamespacePayload @join__type(graph: STRAWBERRY) { id: UUID! diff --git a/packages/backend.ai-ui/src/components/fragments/BAIBucketSelect.tsx b/packages/backend.ai-ui/src/components/fragments/BAIBucketSelect.tsx index 79e217d27f..e5066e1dda 100644 --- a/packages/backend.ai-ui/src/components/fragments/BAIBucketSelect.tsx +++ b/packages/backend.ai-ui/src/components/fragments/BAIBucketSelect.tsx @@ -66,7 +66,7 @@ const BAIBucketSelect = ({ edges { node { id - bucket + namespace } } } @@ -95,7 +95,7 @@ const BAIBucketSelect = ({ ); const selectedOptions = _.map(paginationData, (item) => ({ - label: item.node.bucket, + label: item.node.namespace, value: item.node.id, })); diff --git a/react/src/components/AgentStats.tsx b/react/src/components/AgentStats.tsx new file mode 100644 index 0000000000..7a3df51566 --- /dev/null +++ b/react/src/components/AgentStats.tsx @@ -0,0 +1,222 @@ +import { useResourceSlotsDetails } from '../hooks/backendai'; +import BAIFetchKeyButton from './BAIFetchKeyButton'; +import { useControllableValue } from 'ahooks'; +import { Segmented, Skeleton, theme, Typography } from 'antd'; +import { + BAIFlex, + BAIBoardItemTitle, + ResourceStatistics, + convertToNumber, + processMemoryValue, +} from 'backend.ai-ui'; +import _ from 'lodash'; +import { useMemo, useTransition, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useRefetchableFragment } from 'react-relay'; +import { AgentStatsFragment$key } from 'src/__generated__/AgentStatsFragment.graphql'; + +interface AgentStatsProps { + queryRef: AgentStatsFragment$key; + isRefetching?: boolean; + displayType?: 'used' | 'free'; + onDisplayTypeChange?: (type: 'used' | 'free') => void; + extra?: ReactNode; +} + +const AgentStats: React.FC = ({ + queryRef, + isRefetching, + extra, + ...props +}) => { + const { t } = useTranslation(); + const { token } = theme.useToken(); + + const [isPendingRefetch, startRefetchTransition] = useTransition(); + + const [displayType, setDisplayType] = useControllableValue< + Exclude + >(props, { + defaultValue: 'used', + trigger: 'onDisplayTypeChange', + defaultValuePropName: 'defaultDisplayType', + }); + + const [data, refetch] = useRefetchableFragment( + graphql` + fragment AgentStatsFragment on Query + @refetchable(queryName: "AgentStatsRefetchQuery") { + agentStats @since(version: "25.15.0") { + totalResource { + free + used + capacity + } + } + } + `, + queryRef, + ); + + const resourceSlotsDetails = useResourceSlotsDetails(); + + const agentStatsData = useMemo(() => { + const totalResource = data.agentStats.totalResource; + if (!totalResource) { + return { cpu: null, memory: null, accelerators: [] }; + } + + const free = totalResource.free as Record; + const used = totalResource.used as Record; + const capacity = totalResource.capacity as Record; + + const cpuSlot = resourceSlotsDetails?.resourceSlotsInRG?.['cpu']; + const memSlot = resourceSlotsDetails?.resourceSlotsInRG?.['mem']; + + const cpuData = cpuSlot + ? { + using: { + current: convertToNumber(used['cpu'] || 0), + total: convertToNumber(capacity['cpu'] || 0), + }, + remaining: { + current: convertToNumber(free['cpu'] || 0), + total: convertToNumber(capacity['cpu'] || 0), + }, + metadata: { + title: cpuSlot.human_readable_name, + displayUnit: cpuSlot.display_unit, + }, + } + : null; + + const memoryData = memSlot + ? { + using: { + current: processMemoryValue(used['mem'] || 0, memSlot.display_unit), + total: processMemoryValue( + capacity['mem'] || 0, + memSlot.display_unit, + ), + }, + remaining: { + current: processMemoryValue(free['mem'] || 0, memSlot.display_unit), + total: processMemoryValue( + capacity['mem'] || 0, + memSlot.display_unit, + ), + }, + metadata: { + title: memSlot.human_readable_name, + displayUnit: memSlot.display_unit, + }, + } + : null; + + const accelerators = _.chain(resourceSlotsDetails?.resourceSlotsInRG) + .omit(['cpu', 'mem']) + .map((resourceSlot, key) => { + if (!resourceSlot) return null; + + const freeValue = free[key] || 0; + const usedValue = used[key] || 0; + const capacityValue = capacity[key] || 0; + + return { + key, + using: { + current: convertToNumber(usedValue), + total: convertToNumber(capacityValue), + }, + remaining: { + current: convertToNumber(freeValue), + total: convertToNumber(capacityValue), + }, + metadata: { + title: resourceSlot.human_readable_name, + displayUnit: resourceSlot.display_unit, + }, + }; + }) + .compact() + .filter((item) => !!(item.using.current || item.using.total)) + .value(); + + return { cpu: cpuData, memory: memoryData, accelerators }; + }, [data, resourceSlotsDetails]); + + return ( + + + {t('agentStats.AgentStats')} + + } + tooltip={t('agentStats.AgentStatsDescription')} + extra={ + + > + size="small" + options={[ + { + label: t('agentStats.Used'), + value: 'used', + }, + { + value: 'free', + label: t('agentStats.Free'), + }, + ]} + value={displayType} + onChange={(v) => setDisplayType(v)} + /> + { + startRefetchTransition(() => { + refetch( + {}, + { + fetchPolicy: 'network-only', + }, + ); + }); + }} + type="text" + style={{ + backgroundColor: 'transparent', + }} + /> + {extra} + + } + /> + {resourceSlotsDetails.isLoading ? ( + + ) : ( + + )} + + ); +}; + +export default AgentStats; diff --git a/react/src/pages/AdminDashboardPage.tsx b/react/src/pages/AdminDashboardPage.tsx index 41a1457d27..e4ae1ae870 100644 --- a/react/src/pages/AdminDashboardPage.tsx +++ b/react/src/pages/AdminDashboardPage.tsx @@ -1,7 +1,6 @@ import { AdminDashboardPageQuery } from '../__generated__/AdminDashboardPageQuery.graphql'; import BAIBoard, { BAIBoardItem } from '../components/BAIBoard'; import RecentlyCreatedSession from '../components/RecentlyCreatedSession'; -import ReservedResources from '../components/ReservedResources'; import SessionCountDashboardItem from '../components/SessionCountDashboardItem'; import TotalResourceWithinResourceGroup, { useIsAvailableTotalResourceWithinResourceGroup, @@ -24,6 +23,7 @@ import { Suspense, useTransition } from 'react'; import { useTranslation } from 'react-i18next'; import { graphql, useLazyLoadQuery } from 'react-relay'; import ActiveAgents from 'src/components/ActiveAgents'; +import AgentStats from 'src/components/AgentStats'; import { useCurrentUserRole } from 'src/hooks/backendai'; const AdminDashboardPage: React.FC = () => { @@ -45,15 +45,17 @@ const AdminDashboardPage: React.FC = () => { const isAvailableTotalResourcePanel = useIsAvailableTotalResourceWithinResourceGroup(); + const isAgentStatsSupported = baiClient.supports('agent-stats'); + const queryRef = useLazyLoadQuery( graphql` query AdminDashboardPageQuery( $scopeId: ScopeField $resourceGroup: String $skipTotalResourceWithinResourceGroup: Boolean! + $skipAgentStats: Boolean! $isSuperAdmin: Boolean! $agentNodeFilter: String! - $reservedResourceFilter: String! ) { ...SessionCountDashboardItemQueryFragment @arguments(scopeId: $scopeId) ...RecentlyCreatedSessionFragment @arguments(scopeId: $scopeId) @@ -65,17 +67,16 @@ const AdminDashboardPage: React.FC = () => { isSuperAdmin: $isSuperAdmin agentNodeFilter: $agentNodeFilter ) - ...ReservedResourcesFragment - @arguments(reservedResourceFilter: $reservedResourceFilter) + ...AgentStatsFragment @skip(if: $skipAgentStats) @alias } `, { scopeId: `project:${currentProject.id}`, resourceGroup: currentResourceGroup || 'default', skipTotalResourceWithinResourceGroup: !isAvailableTotalResourcePanel, + skipAgentStats: !isAgentStatsSupported, isSuperAdmin: _.isEqual(userRole, 'superadmin'), agentNodeFilter: `schedulable == true & status == "ALIVE" & scaling_group == "${currentResourceGroup}"`, - reservedResourceFilter: 'schedulable == true & status == "ALIVE"', }, { fetchPolicy: @@ -132,29 +133,33 @@ const AdminDashboardPage: React.FC = () => { ), }, }, - baiClient.supports('agent-nodes-query') && { - id: 'reservedResources', - rowSpan: 2, - columnSpan: 2, - definition: { - minRowSpan: 2, - minColumnSpan: 2, + isAgentStatsSupported && + queryRef.AgentStatsFragment && { + id: 'agentStats', + rowSpan: 2, + columnSpan: 2, + definition: { + minRowSpan: 2, + minColumnSpan: 2, + }, + data: { + content: ( + + } + > + + + ), + }, }, - data: { - content: ( - - } - > - - - ), - }, - }, { id: 'activeAgents', rowSpan: 4, diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 7c15af9bd2..72e56127ac 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -31,6 +31,13 @@ "Terminated": "Terminated", "Utilization": "Utilization" }, + "agentStats": { + "AgentStats": "Agent Statistics", + "AgentStatsDescription": "This panel shows all used resources across all agents in the system. The values represent the total used resources by all active sessions.", + "Free": "Free", + "Used": "Used", + "UsedResources": "Used Resources" + }, "autoScalingRule": { "AddAutoScalingRule": "Add Auto Scaling Rule", "Comparator": "Comparator", @@ -1104,12 +1111,6 @@ "Username": "Username", "UsernameOptional": "Username (Optional)" }, - "reservedResources": { - "Free": "Free", - "Reserved": "Reserved", - "ReservedResources": "Reserved Resources", - "ReservedResourcesDescription": "This panel shows all reserved resources across all agents in the system. The values represent the total used resources by all active sessions." - }, "reservoirPage": { "Activate": "Activate", "Active": "Active", diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json index ffaf4dc9ff..c0c4db7e9b 100644 --- a/resources/i18n/ko.json +++ b/resources/i18n/ko.json @@ -1103,10 +1103,6 @@ "Username": "사용자 이름", "UsernameOptional": "사용자 이름 (선택)" }, - "reservedResources": { - "ReservedResources": "점유 자원 현황", - "ReservedResourcesDescription": "이 패널은 시스템 전체 에이전트에서 점유된 모든 자원 현황을 보여줍니다. 모든 활성 세션이 점유한 총 자원량을 나타냅니다." - }, "reservoirPage": { "Activate": "활성화", "Active": "활성", diff --git a/src/lib/backend.ai-client-esm.ts b/src/lib/backend.ai-client-esm.ts index d52d59eea1..c1b736069c 100644 --- a/src/lib/backend.ai-client-esm.ts +++ b/src/lib/backend.ai-client-esm.ts @@ -786,7 +786,6 @@ class Client { this._features['max_network_count'] = true; this._features['replicas'] = true; this._features['base-image-name'] = true; - this._features['agent-nodes-query'] = true; } if (this.isManagerVersionCompatibleWith(['25.1.0', '24.09.6', '24.03.12'])) { this._features['vfolder-id-based'] = true; @@ -822,6 +821,9 @@ class Client { if (this.isManagerVersionCompatibleWith('25.12.0')) { this._features['reservoir'] = true; } + if (this.isManagerVersionCompatibleWith('25.15.0')) { + this._features['agent-stats'] = true; + } } /**