diff --git a/packages/backend.ai-ui/src/helper/index.ts b/packages/backend.ai-ui/src/helper/index.ts index 2bef11a980..6052111a5d 100644 --- a/packages/backend.ai-ui/src/helper/index.ts +++ b/packages/backend.ai-ui/src/helper/index.ts @@ -386,6 +386,38 @@ export const omitNullAndUndefinedFields = >( ) as Partial; }; +/** + * Safely parses an unknown input into a plain object record. + * + * Accepts either: + * - a non-null object (not an array) returned as-is + * - a JSON string starting with '{' which will be parsed + * Otherwise returns an empty object. + * + * + * @param raw - Unknown input value that may be an object or JSON string + * @returns A record object or an empty object if parsing fails + */ +export function parseObjectMap(raw: unknown): Record { + if (!raw) return {}; + if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) { + return raw as Record; + } + if (typeof raw === 'string') { + const s = raw.trim(); + if (!s.startsWith('{')) return {}; + try { + const parsed = JSON.parse(s); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return {}; + } + } + return {}; +} + /** * Generates a random string of alphabetic characters. * diff --git a/react/src/components/AgentList.tsx b/react/src/components/AgentList.tsx index 82d574e0bd..f409484864 100644 --- a/react/src/components/AgentList.tsx +++ b/react/src/components/AgentList.tsx @@ -3,7 +3,6 @@ import { AgentListQuery, AgentListQuery$data, } from '../__generated__/AgentListQuery.graphql'; -import { AgentSettingModalFragment$key } from '../__generated__/AgentSettingModalFragment.graphql'; import { convertToBinaryUnit, convertToDecimalUnit, @@ -16,7 +15,7 @@ import { useBAIPaginationOptionStateOnSearchParamLegacy } from '../hooks/reactPa import { useHiddenColumnKeysSetting } from '../hooks/useHiddenColumnKeysSetting'; import { useThemeMode } from '../hooks/useThemeMode'; import AgentDetailModal from './AgentDetailModal'; -import AgentSettingModal from './AgentSettingModal'; +import AgentSettingModalLegacy from './AgentSettingModalLegacy'; import BAIIntervalView from './BAIIntervalView'; import BAIProgressWithLabel from './BAIProgressWithLabel'; import BAIRadioGroup from './BAIRadioGroup'; @@ -47,6 +46,7 @@ import _ from 'lodash'; import React, { useState, useDeferredValue } from 'react'; import { useTranslation } from 'react-i18next'; import { graphql, useLazyLoadQuery } from 'react-relay'; +import { AgentSettingModalLegacyFragment$key } from 'src/__generated__/AgentSettingModalLegacyFragment.graphql'; import { StringParam, useQueryParams, withDefault } from 'use-query-params'; type Agent = NonNullable['items'][number]; @@ -71,7 +71,7 @@ const AgentList: React.FC = ({ const [currentAgentInfo, setCurrentAgentInfo] = useState(); const [currentSettingAgent, setCurrentSettingAgent] = - useState(); + useState(); const [visibleColumnSettingModal, { toggle: toggleColumnSettingModal }] = useToggle(); const baiClient = useSuspendedBackendaiClient(); @@ -146,7 +146,7 @@ const AgentList: React.FC = ({ scaling_group schedulable ...AgentDetailModalFragment - ...AgentSettingModalFragment + ...AgentSettingModalLegacyFragment } total_count } @@ -960,8 +960,8 @@ const AgentList: React.FC = ({ open={!!currentAgentInfo} onRequestClose={() => setCurrentAgentInfo(null)} /> - { if (success) { diff --git a/react/src/components/AgentNodes.tsx b/react/src/components/AgentNodes.tsx new file mode 100644 index 0000000000..a37b9a6863 --- /dev/null +++ b/react/src/components/AgentNodes.tsx @@ -0,0 +1,1037 @@ +import { + AgentNodesFragment$data, + AgentNodesFragment$key, +} from '../__generated__/AgentNodesFragment.graphql'; +import { + convertToBinaryUnit, + convertToDecimalUnit, + convertUnitValue, + toFixedFloorWithoutTrailingZeros, +} from '../helper'; +import { useSuspendedBackendaiClient } from '../hooks'; +import { useResourceSlotsDetails } from '../hooks/backendai'; +import { useThemeMode } from '../hooks/useThemeMode'; +import AgentSettingModal from './AgentSettingModal'; +import BAIIntervalView from './BAIIntervalView'; +import BAIProgressWithLabel from './BAIProgressWithLabel'; +import DoubleTag from './DoubleTag'; +import { ResourceTypeIcon } from './ResourceNumber'; +import { + CheckCircleOutlined, + MinusCircleOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import { Button, Tag, theme } from 'antd'; +import { + filterOutEmpty, + filterOutNullAndUndefined, + BAIColumnType, + BAITable, + BAITableProps, + BAIFlex, + toLocalId, + BAIText, + parseObjectMap, +} from 'backend.ai-ui'; +import dayjs from 'dayjs'; +import _ from 'lodash'; +import React, { Suspense, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useFragment } from 'react-relay'; +import { AgentSettingModalFragment$key } from 'src/__generated__/AgentSettingModalFragment.graphql'; + +export type AgentNodeInList = NonNullable; + +const availableAgentSorterKeys = [ + 'id', + 'status', + 'status_changed', + 'region', + 'scaling_group', + 'schedulable', + 'first_contact', + 'lost_at', + 'version', + 'available_slots', + 'occupied_slots', +] as const; + +const platformData: { + [key: string]: { color: string; icon: string }; +} = { + aws: { color: 'orange', icon: 'aws' }, + amazon: { color: 'orange', icon: 'aws' }, + azure: { color: 'blue', icon: 'azure' }, + gcp: { color: 'lightblue', icon: 'gcp' }, + google: { color: 'lightblue', icon: 'gcp' }, + nbp: { color: 'green', icon: 'nbp' }, + naver: { color: 'green', icon: 'nbp' }, + openstack: { color: 'red', icon: 'openstack' }, + dgx: { color: 'green', icon: 'local' }, + local: { color: 'yellow', icon: 'local' }, +} as const; + +const RESOURCE_USAGE_WARNING_THRESHOLD = 80; + +export const availableAgentSorterValues = [ + ...availableAgentSorterKeys, + ...availableAgentSorterKeys.map((k) => `-${k}` as const), +] as const; + +const isEnableSorter = (key: string) => + _.includes(availableAgentSorterKeys, key); + +interface AgentNodesProps + extends Omit< + BAITableProps, + 'dataSource' | 'columns' | 'onChangeOrder' + > { + agentsFrgmt: AgentNodesFragment$key; + disableSorter?: boolean; + onChangeOrder?: ( + order: (typeof availableAgentSorterValues)[number] | null, + ) => void; +} + +const AgentNodes: React.FC = ({ + agentsFrgmt, + disableSorter, + onChangeOrder, + ...tableProps +}) => { + 'use memo'; + const { t } = useTranslation(); + const { token } = theme.useToken(); + const { isDarkMode } = useThemeMode(); + const { mergedResourceSlots } = useResourceSlotsDetails(); + const baiClient = useSuspendedBackendaiClient(); + const [currentSettingAgent, setCurrentSettingAgent] = + useState(null); + + const agents = useFragment( + graphql` + fragment AgentNodesFragment on AgentNode @relay(plural: true) { + id @required(action: NONE) + region + scaling_group + schedulable + available_slots + occupied_slots + addr + first_contact + live_stat + version + compute_plugins + status + status_changed + lost_at + hardware_metadata + auto_terminate_abusing_kernel + local_config + container_count + gpu_alloc_map + permissions + ...AgentSettingModalFragment + } + `, + agentsFrgmt, + ); + + const filteredAgents = filterOutNullAndUndefined(agents); + + const columns = _.map( + filterOutEmpty>([ + { + key: 'id', + dataIndex: 'id', + title: `ID / ${t('agent.Endpoint')}`, + render: (value, agent) => ( + + {toLocalId(value)} + {agent?.addr} + + ), + sorter: isEnableSorter('id'), + required: true, + fixed: 'left', + }, + { + key: 'controls', + title: t('general.Control'), + fixed: 'left', + render: (_value, agent) => { + return ( + +